引言
在当今数字化时代,拥有一个个人博客已成为分享知识、建立个人品牌和连接志同道合者的重要方式。本文将详细介绍如何使用现代Web技术栈创建一个功能完善的博客系统,涵盖从项目规划到部署上线的完整流程。
1. 项目规划与技术选型
1.1 明确需求分析
在开始编码之前,我们需要明确博客系统的核心功能:
- 内容管理:文章的创建、编辑、删除和分类
- 用户系统:用户注册、登录、权限管理
- 交互功能:评论、点赞、分享
- SEO优化:友好的URL、元标签、站点地图
- 性能优化:缓存、图片压缩、CDN加速
1.2 技术栈选择
基于现代Web开发的最佳实践,推荐以下技术组合:
前端技术:
- React.js 或 Vue.js 作为前端框架
- Tailwind CSS 或 Material-UI 用于样式
- Next.js 或 Nuxt.js 用于服务端渲染
后端技术:
- Node.js + Express.js 或 Python + Django
- MongoDB 或 PostgreSQL 作为数据库
- Redis 用于缓存
部署方案:
- Docker 容器化部署
- Nginx 作为反向代理
- AWS/阿里云/腾讯云作为云服务提供商
2. 数据库设计
2.1 数据模型设计
一个完整的博客系统需要精心设计的数据库结构。以下是使用MongoDB的示例设计:
// 用户模型 (models/User.js)
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
minlength: 3,
maxlength: 30
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, '请输入有效的邮箱地址']
},
password: {
type: String,
required: true,
minlength: 6
},
avatar: {
type: String,
default: '/images/default-avatar.png'
},
bio: {
type: String,
default: '',
maxlength: 500
},
role: {
type: String,
enum: ['user', 'admin', 'editor'],
default: 'user'
},
isActive: {
type: Boolean,
default: true
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
}
});
// 密码加密中间件
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
try {
const salt = await bcrypt.genSalt(12);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
// 密码验证方法
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
2.2 文章模型设计
// 文章模型 (models/Post.js)
const mongoose = require('mongoose');
const postSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
maxlength: 200
},
slug: {
type: String,
required: true,
unique: true,
lowercase: true,
match: [/^[a-z0-9-]+$/, 'URL只能包含小写字母、数字和连字符']
},
content: {
type: String,
required: true
},
excerpt: {
type: String,
maxlength: 300
},
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
categories: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Category'
}],
tags: [String],
coverImage: {
type: String
},
status: {
type: String,
enum: ['draft', 'published', 'archived'],
default: 'draft'
},
views: {
type: Number,
default: 0
},
likes: {
type: Number,
default: 0
},
commentsEnabled: {
type: Boolean,
default: true
},
publishedAt: Date,
seo: {
metaTitle: String,
metaDescription: String,
focusKeyword: String
}
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// 虚拟字段:计算阅读时间
postSchema.virtual('readingTime').get(function() {
const wordsPerMinute = 200;
const words = this.content.split(/\s+/).length;
return Math.ceil(words / wordsPerMinute);
});
// 索引优化
postSchema.index({ slug: 1 });
postSchema.index({ author: 1 });
postSchema.index({ status: 1, publishedAt: -1 });
postSchema.index({ tags: 1 });
postSchema.index({ 'seo.focusKeyword': 1 });
module.exports = mongoose.model('Post', postSchema);
3. 后端API开发
3.1 用户认证系统
// middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
// JWT验证中间件
const auth = async (req, res, next) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
success: false,
message: '访问令牌缺失'
});
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findOne({
_id: decoded._id,
isActive: true
});
if (!user) {
return res.status(401).json({
success: false,
message: '用户不存在或已被禁用'
});
}
req.user = user;
next();
} catch (error) {
res.status(401).json({
success: false,
message: '访问令牌无效'
});
}
};
// 角色权限中间件
const authorize = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
message: '权限不足,需要以下角色之一:' + roles.join(', ')
});
}
next();
};
};
module.exports = { auth, authorize };
3.2 文章管理API
// controllers/postController.js
const Post = require('../models/Post');
const slugify = require('slugify');
const { validationResult } = require('express-validator');
// 创建文章
exports.createPost = async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
const { title, content, categories, tags, status, excerpt, seo } = req.body;
// 生成URL友好的slug
const slug = slugify(title, {
lower: true,
strict: true,
remove: /[*+~.()'"!:@]/g
});
// 检查slug是否已存在
const existingPost = await Post.findOne({ slug });
if (existingPost) {
return res.status(400).json({
success: false,
message: '文章标题已存在,请修改标题'
});
}
// 创建文章
const post = new Post({
title,
slug,
content,
author: req.user._id,
categories: categories || [],
tags: tags || [],
status: status || 'draft',
excerpt: excerpt || content.substring(0, 300),
seo: seo || {}
});
// 如果是发布状态,设置发布时间
if (status === 'published') {
post.publishedAt = new Date();
}
await post.save();
// 填充作者信息
await post.populate('author', 'username avatar');
res.status(201).json({
success: true,
message: '文章创建成功',
data: post
});
} catch (error) {
console.error('创建文章错误:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
};
// 获取文章列表(带分页和过滤)
exports.getPosts = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
const { category, tag, author, status, search } = req.query;
const filter = {};
// 构建过滤条件
if (category) filter.categories = category;
if (tag) filter.tags = tag;
if (author) filter.author = author;
if (status) filter.status = status;
if (search) {
filter.$or = [
{ title: { $regex: search, $options: 'i' } },
{ content: { $regex: search, $options: 'i' } },
{ excerpt: { $regex: search, $options: 'i' } }
];
}
// 只返回已发布的文章(除非是管理员)
if (!req.user || req.user.role !== 'admin') {
filter.status = 'published';
}
const posts = await Post.find(filter)
.populate('author', 'username avatar')
.populate('categories', 'name slug')
.sort({ publishedAt: -1, createdAt: -1 })
.skip(skip)
.limit(limit)
.lean();
const total = await Post.countDocuments(filter);
const totalPages = Math.ceil(total / limit);
res.json({
success: true,
data: {
posts,
pagination: {
currentPage: page,
totalPages,
totalPosts: total,
hasNext: page < totalPages,
hasPrev: page > 1
}
}
});
} catch (error) {
console.error('获取文章列表错误:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
};
// 获取单篇文章
exports.getPostBySlug = async (req, res) => {
try {
const { slug } = req.params;
const post = await Post.findOne({ slug })
.populate('author', 'username avatar bio')
.populate('categories', 'name slug')
.lean();
if (!post) {
return res.status(404).json({
success: false,
message: '文章不存在'
});
}
// 增加浏览次数(异步,不阻塞响应)
Post.updateOne({ slug }, { $inc: { views: 1 } }).exec();
res.json({
success: true,
data: post
});
} catch (error) {
console.error('获取文章错误:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
};
// 更新文章
exports.updatePost = async (req, res) => {
try {
const { slug } = req.params;
const updates = req.body;
const user = req.user;
const post = await Post.findOne({ slug });
if (!post) {
return res.status(404).json({
success: false,
message: '文章不存在'
});
}
// 权限检查:只有作者或管理员可以编辑
if (post.author.toString() !== user._id.toString() && user.role !== 'admin') {
return res.status(403).json({
success: false,
message: '无权编辑此文章'
});
}
// 如果更新标题,重新生成slug
if (updates.title && updates.title !== post.title) {
updates.slug = slugify(updates.title, {
lower: true,
strict: true,
remove: /[*+~.()'"!:@]/g
});
}
// 如果状态改为发布,设置发布时间
if (updates.status === 'published' && post.status !== 'published') {
updates.publishedAt = new Date();
}
const updatedPost = await Post.findOneAndUpdate(
{ slug },
{ ...updates, updatedAt: new Date() },
{ new: true, runValidators: true }
).populate('author', 'username avatar')
.populate('categories', 'name slug');
res.json({
success: true,
message: '文章更新成功',
data: updatedPost
});
} catch (error) {
console.error('更新文章错误:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
};
// 删除文章
exports.deletePost = async (req, res) => {
try {
const { slug } = req.params;
const user = req.user;
const post = await Post.findOne({ slug });
if (!post) {
return res.status(404).json({
success: false,
message: '文章不存在'
});
}
// 权限检查
if (post.author.toString() !== user._id.toString() && user.role !== 'admin') {
return res.status(403).json({
success: false,
message: '无权删除此文章'
});
}
await Post.findOneAndDelete({ slug });
res.json({
success: true,
message: '文章删除成功'
});
} catch (error) {
console.error('删除文章错误:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
};
4. 前端实现
4.1 React组件:文章列表
// components/PostList.jsx
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import PostCard from './PostCard';
import Pagination from './Pagination';
import FilterBar from './FilterBar';
const PostList = () => {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [pagination, setPagination] = useState({
currentPage: 1,
totalPages: 1,
totalPosts: 0
});
const [filters, setFilters] = useState({
category: '',
tag: '',
search: '',
status: 'published'
});
// 获取文章列表
const fetchPosts = async (page = 1) => {
setLoading(true);
setError(null);
try {
const params = {
page,
limit: 10,
...filters
};
// 移除空值参数
Object.keys(params).forEach(key => {
if (params[key] === '') delete params[key];
});
const response = await axios.get('/api/posts', { params });
setPosts(response.data.data.posts);
setPagination(response.data.data.pagination);
} catch (err) {
setError(err.response?.data?.message || '获取文章失败');
console.error('获取文章错误:', err);
} finally {
setLoading(false);
}
};
// 页面加载时获取数据
useEffect(() => {
fetchPosts(1);
}, [filters]);
// 处理筛选变化
const handleFilterChange = (newFilters) => {
setFilters(prev => ({ ...prev, ...newFilters }));
};
// 处理分页
const handlePageChange = (page) => {
fetchPosts(page);
};
if (loading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">加载中...</span>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
错误:{error}
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">博客文章</h1>
<p className="text-gray-600">
共 {pagination.totalPosts} 篇文章
</p>
</div>
{/* 筛选栏 */}
<FilterBar
filters={filters}
onFilterChange={handleFilterChange}
/>
{/* 文章列表 */}
{posts.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">暂无文章</p>
</div>
) : (
<div className="grid gap-6 mb-8">
{posts.map(post => (
<PostCard key={post._id} post={post} />
))}
</div>
)}
{/* 分页 */}
{pagination.totalPages > 1 && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePageChange}
/>
)}
</div>
);
};
export default PostList;
4.2 React组件:文章卡片
// components/PostCard.jsx
import React from 'react';
import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
const PostCard = ({ post }) => {
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '';
return formatDistanceToNow(new Date(dateString), {
addSuffix: true,
locale: zhCN
});
};
// 获取阅读时间
const getReadingTime = () => {
return post.readingTime || Math.ceil(post.content.split(/\s+/).length / 200);
};
// 获取标签颜色
const getTagColor = (index) => {
const colors = [
'bg-blue-100 text-blue-800',
'bg-green-100 text-green-800',
'bg-purple-100 text-purple-800',
'bg-pink-100 text-pink-800',
'bg-yellow-100 text-yellow-800'
];
return colors[index % colors.length];
};
return (
<article className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 overflow-hidden">
{/* 封面图片 */}
{post.coverImage && (
<Link to={`/post/${post.slug}`} className="block">
<img
src={post.coverImage}
alt={post.title}
className="w-full h-48 object-cover hover:opacity-90 transition-opacity"
loading="lazy"
/>
</Link>
)}
<div className="p-6">
{/* 文章元信息 */}
<div className="flex items-center text-sm text-gray-500 mb-3 space-x-4">
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z"/>
</svg>
{post.author?.username || '未知作者'}
</span>
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd"/>
</svg>
{formatDate(post.createdAt)}
</span>
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd"/>
</svg>
{getReadingTime()} 分钟阅读
</span>
</div>
{/* 文章标题 */}
<h2 className="text-xl font-bold text-gray-900 mb-2 hover:text-blue-600 transition-colors">
<Link to={`/post/${post.slug}`}>
{post.title}
</Link>
</h2>
{/* 摘要 */}
{post.excerpt && (
<p className="text-gray-600 mb-4 line-clamp-3">
{post.excerpt}
</p>
)}
{/* 分类和标签 */}
<div className="flex flex-wrap gap-2 mb-4">
{post.categories?.map(category => (
<Link
key={category._id}
to={`/category/${category.slug}`}
className="text-xs px-2 py-1 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 transition-colors"
>
{category.name}
</Link>
))}
{post.tags?.slice(0, 3).map((tag, index) => (
<span
key={index}
className={`text-xs px-2 py-1 rounded ${getTagColor(index)}`}
>
#{tag}
</span>
))}
</div>
{/* 底部操作栏 */}
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="flex items-center space-x-4 text-sm text-gray-500">
<span className="flex items-center hover:text-gray-700">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{post.views?.toLocaleString() || 0}
</span>
<span className="flex items-center hover:text-gray-700">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
</svg>
{post.likes?.toLocaleString() || 0}
</span>
</div>
<Link
to={`/post/${post.slug}`}
className="text-blue-600 hover:text-blue-800 font-medium text-sm flex items-center"
>
阅读全文
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
</article>
);
};
export default PostCard;
5. 高级功能实现
5.1 评论系统
// models/Comment.js
const mongoose = require('mongoose');
const commentSchema = new mongoose.Schema({
content: {
type: String,
required: true,
trim: true,
minlength: 2,
maxlength: 1000
},
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
post: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Post',
required: true,
index: true
},
parent: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Comment',
default: null
},
likes: {
type: Number,
default: 0
},
status: {
type: String,
enum: ['pending', 'approved', 'rejected'],
default: 'pending'
}
}, {
timestamps: true
});
// 递归获取评论及其回复
commentSchema.statics.getCommentsWithReplies = async function(postId, page = 1, limit = 10) {
const skip = (page - 1) * limit;
const comments = await this.find({
post: postId,
parent: null,
status: 'approved'
})
.populate('author', 'username avatar')
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean();
// 获取每个评论的回复
for (let comment of comments) {
comment.replies = await this.find({
parent: comment._id,
status: 'approved'
})
.populate('author', 'username avatar')
.sort({ createdAt: 1 })
.lean();
}
return comments;
};
module.exports = mongoose.model('Comment', commentSchema);
5.2 缓存优化
// utils/cache.js
const redis = require('redis');
const { promisify } = require('util');
class CacheManager {
constructor() {
this.client = redis.createClient({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD,
retry_strategy: (options) => {
if (options.error && options.error.code === 'ECONNREFUSED') {
return new Error('Redis连接被拒绝');
}
if (options.total_retry_time > 1000 * 60 * 60) {
return new Error('重试时间超过限制');
}
if (options.attempt > 10) {
return undefined;
}
return Math.min(options.attempt * 100, 3000);
}
});
this.client.on('error', (err) => {
console.error('Redis错误:', err);
});
// 将回调函数转换为Promise
this.getAsync = promisify(this.client.get).bind(this.client);
this.setAsync = promisify(this.client.set).bind(this.client);
this.delAsync = promisify(this.client.del).bind(this.client);
this.keysAsync = promisify(this.client.keys).bind(this.client);
}
// 设置缓存
async set(key, value, ttl = 3600) {
try {
const serializedValue = JSON.stringify(value);
await this.setAsync(key, serializedValue, 'EX', ttl);
return true;
} catch (error) {
console.error('设置缓存失败:', error);
return false;
}
}
// 获取缓存
async get(key) {
try {
const value = await this.getAsync(key);
return value ? JSON.parse(value) : null;
} catch (error) {
console.error('获取缓存失败:', error);
return null;
}
}
// 删除缓存
async delete(key) {
try {
await this.delAsync(key);
return true;
} catch (error) {
console.error('删除缓存失败:', error);
return false;
}
}
// 删除匹配的缓存
async deletePattern(pattern) {
try {
const keys = await this.keysAsync(pattern);
if (keys.length > 0) {
await this.delAsync(...keys);
}
return true;
} catch (error) {
console.error('批量删除缓存失败:', error);
return false;
}
}
// 缓存包装器
async cacheable(key, fetchFn, ttl = 3600) {
// 先尝试从缓存获取
const cached = await this.get(key);
if (cached !== null) {
return cached;
}
// 缓存未命中,执行原始函数
const freshData = await fetchFn();
// 存入缓存
await this.set(key, freshData, ttl);
return freshData;
}
}
module.exports = new CacheManager();
5.3 使用缓存的API示例
// controllers/postController.js (使用缓存版本)
const cache = require('../utils/cache');
const Post = require('../models/Post');
// 带缓存的文章列表
exports.getPostsCached = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const { category, tag, search } = req.query;
// 生成缓存key
const cacheKey = `posts:page:${page}:limit:${limit}:cat:${category || 'all'}:tag:${tag || 'all'}:search:${search || 'none'}`;
// 使用缓存包装器
const data = await cache.cacheable(
cacheKey,
async () => {
const filter = {};
if (category) filter.categories = category;
if (tag) filter.tags = tag;
if (search) {
filter.$or = [
{ title: { $regex: search, $options: 'i' } },
{ content: { $regex: search, $options: 'i' } }
];
}
filter.status = 'published';
const posts = await Post.find(filter)
.populate('author', 'username avatar')
.populate('categories', 'name slug')
.sort({ publishedAt: -1 })
.skip((page - 1) * limit)
.limit(limit)
.lean();
const total = await Post.countDocuments(filter);
const totalPages = Math.ceil(total / limit);
return {
posts,
pagination: {
currentPage: page,
totalPages,
totalPosts: total
}
};
},
300 // 5分钟缓存
);
res.json({
success: true,
data: data
});
} catch (error) {
console.error('获取缓存文章错误:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
};
// 清除文章相关缓存
exports.clearPostCache = async (req, res) => {
try {
// 删除所有文章列表缓存
await cache.deletePattern('posts:*');
res.json({
success: true,
message: '文章缓存已清除'
});
} catch (error) {
console.error('清除缓存错误:', error);
res.status(500).json({
success: false,
message: '清除缓存失败'
});
}
};
6. 部署与运维
6.1 Docker配置
# Dockerfile
# 使用多阶段构建优化镜像大小
FROM node:18-alpine AS builder
WORKDIR /app
# 复制package.json和lock文件
COPY package*.json ./
# 安装依赖(包括devDependencies用于构建)
RUN npm ci
# 复制源代码
COPY . .
# 构建应用(如果有前端构建步骤)
RUN npm run build
# 生产阶段
FROM node:18-alpine AS production
WORKDIR /app
# 仅安装生产依赖
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# 从builder阶段复制构建产物
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./
# 创建非root用户
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
USER nodejs
# 暴露端口
EXPOSE 3000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node healthcheck.js
# 启动命令
CMD ["node", "dist/server.js"]
6.2 Docker Compose配置
# docker-compose.yml
version: '3.8'
services:
# Web应用
app:
build:
context: .
target: production
container_name: blog-app
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=mongodb://mongodb:27017/blog
- REDIS_HOST=redis
- REDIS_PORT=6379
- JWT_SECRET=${JWT_SECRET}
ports:
- "3000:3000"
depends_on:
- mongodb
- redis
networks:
- blog-network
volumes:
- ./logs:/app/logs
- ./uploads:/app/public/uploads
# MongoDB数据库
mongodb:
image: mongo:6.0
container_name: blog-mongodb
restart: unless-stopped
environment:
- MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD}
- MONGO_INITDB_DATABASE=blog
ports:
- "27017:27017"
volumes:
- mongodb_data:/data/db
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
networks:
- blog-network
# Redis缓存
redis:
image: redis:7-alpine
container_name: blog-redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- blog-network
# Nginx反向代理
nginx:
image: nginx:alpine
container_name: blog-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
- ./logs/nginx:/var/log/nginx
depends_on:
- app
networks:
- blog-network
# 自动备份服务
backup:
image: alpine:latest
container_name: blog-backup
restart: unless-stopped
volumes:
- mongodb_data:/data/backup
- ./backup.sh:/backup.sh
command: sh -c "apk add --no-cache mongodb-tools && chmod +x /backup.sh && /backup.sh"
environment:
- MONGO_USER=${MONGO_USER}
- MONGO_PASSWORD=${MONGO_PASSWORD}
networks:
- blog-network
networks:
blog-network:
driver: bridge
volumes:
mongodb_data:
redis_data:
6.3 Nginx配置
# nginx.conf
events {
worker_connections 1024;
}
http {
# 基础配置
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# 性能优化
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/xml+rss
application/json;
# 限流配置
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/m;
# Upstream
upstream app {
server app:3000;
}
# HTTP重定向到HTTPS
server {
listen 80;
server_name your-domain.com www.your-domain.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$server_name$request_uri;
}
}
# HTTPS配置
server {
listen 443 ssl http2;
server_name your-domain.com www.your-domain.com;
# SSL证书
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# 安全头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 静态文件
location /static/ {
alias /app/public/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# 上传文件
location /uploads/ {
alias /app/public/uploads/;
expires 1y;
add_header Cache-Control "public";
# 限制文件类型
location ~ \.(jpg|jpeg|png|gif|ico|css|js|svg|webp)$ {
expires 1y;
}
location ~ \.(php|py|sh|cgi)$ {
deny all;
}
}
# API接口
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# 认证相关接口(更严格的限流)
location /api/auth/ {
limit_req zone=login burst=3 nodelay;
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 主应用
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# 错误页面
proxy_intercept_errors on;
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
}
# 响应压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}
}
7. 安全最佳实践
7.1 输入验证和清理
// middleware/validation.js
const { body, param, query, validationResult } = require('express-validator');
const slugify = require('slugify');
// 文章创建验证
const postValidationRules = () => {
return [
body('title')
.trim()
.isLength({ min: 5, max: 200 })
.withMessage('标题长度必须在5-200个字符之间')
.escape(),
body('content')
.trim()
.isLength({ min: 10 })
.withMessage('内容至少需要10个字符')
.customSanitizer(value => {
// 清理HTML标签,防止XSS
return value.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
}),
body('slug')
.optional()
.trim()
.matches(/^[a-z0-9-]+$/)
.withMessage('URL只能包含小写字母、数字和连字符')
.custom((value, { req }) => {
if (!value && req.body.title) {
req.body.slug = slugify(req.body.title, { lower: true, strict: true });
}
return true;
}),
body('email')
.isEmail()
.normalizeEmail()
.withMessage('请输入有效的邮箱地址'),
body('tags')
.optional()
.isArray({ max: 10 })
.withMessage('最多只能添加10个标签')
.custom(tags => tags.every(tag => typeof tag === 'string' && tag.length <= 20))
.withMessage('每个标签长度不能超过20个字符'),
body('categories')
.optional()
.isArray()
.withMessage('分类必须是数组')
];
};
// 参数验证
const paramValidationRules = () => {
return [
param('slug')
.trim()
.matches(/^[a-z0-9-]+$/)
.withMessage('无效的URL格式')
];
};
// 查询参数验证
const queryValidationRules = () => {
return [
query('page')
.optional()
.isInt({ min: 1 })
.withMessage('页码必须是大于0的整数')
.toInt(),
query('limit')
.optional()
.isInt({ min: 1, max: 50 })
.withMessage('每页数量必须在1-50之间')
.toInt(),
query('search')
.optional()
.trim()
.escape()
];
};
// 验证结果处理中间件
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: '参数验证失败',
errors: errors.array().map(err => ({
field: err.param,
message: err.msg,
value: err.value
}))
});
}
next();
};
module.exports = {
postValidationRules,
paramValidationRules,
queryValidationRules,
validate
};
7.2 安全中间件
// middleware/security.js
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');
const hpp = require('hpp');
const cors = require('cors');
// Helmet配置(设置安全HTTP头)
const helmetConfig = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"]
}
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: true,
crossOriginResourcePolicy: { policy: "cross-origin" },
dnsPrefetchControl: true,
frameguard: { action: 'deny' },
hidePoweredBy: true,
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
ieNoOpen: true,
noSniff: true,
originAgentCluster: true,
permittedCrossDomainPolicies: false,
referrerPolicy: { policy: "no-referrer" },
xssFilter: true
});
// API限流
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 100, // 每个IP最多100次请求
message: {
success: false,
message: '请求过于频繁,请稍后再试'
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
// 跳过健康检查
return req.path === '/health';
}
});
// 严格限流(用于登录等敏感操作)
const strictLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1小时
max: 5, // 每个IP最多5次
message: {
success: false,
message: '请求过于频繁,请1小时后再试'
},
standardHeaders: true,
legacyHeaders: false
});
// CORS配置
const corsOptions = {
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
exposedHeaders: ['X-Total-Count'],
maxAge: 86400 // 24小时
};
// 错误处理中间件
const errorHandler = (err, req, res, next) => {
// 记录错误
console.error('Error:', {
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
ip: req.ip,
userAgent: req.get('User-Agent')
});
// MongoDB重复键错误
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
return res.status(409).json({
success: false,
message: `${field} 已存在`
});
}
// JWT验证错误
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
message: '无效的访问令牌'
});
}
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
message: '访问令牌已过期'
});
}
// 验证错误
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(val => val.message);
return res.status(400).json({
success: false,
message: '数据验证失败',
errors
});
}
// 生产环境错误处理
if (process.env.NODE_ENV === 'production') {
return res.status(err.status || 500).json({
success: false,
message: err.message || '服务器内部错误'
});
}
// 开发环境详细错误
res.status(err.status || 500).json({
success: false,
message: err.message || '服务器内部错误',
stack: err.stack
});
};
module.exports = {
helmetConfig,
apiLimiter,
strictLimiter,
corsOptions,
errorHandler,
mongoSanitize,
xss,
hpp,
cors
};
8. 性能优化策略
8.1 数据库索引优化
// 在模型定义中添加复合索引
// models/Post.js
// 用于首页查询的复合索引
postSchema.index({ status: 1, publishedAt: -1 });
// 用于分类和标签过滤
postSchema.index({ categories: 1, status: 1, publishedAt: -1 });
postSchema.index({ tags: 1, status: 1, publishedAt: -1 });
// 用于搜索
postSchema.index({
title: 'text',
content: 'text',
excerpt: 'text'
}, {
weights: { title: 10, excerpt: 5, content: 2 },
name: 'post_text_index'
});
// 用于作者查询
postSchema.index({ author: 1, status: 1, publishedAt: -1 });
// 用于聚合统计
postSchema.index({ status: 1, categories: 1, tags: 1 });
8.2 图片优化
// utils/imageOptimizer.js
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
class ImageOptimizer {
constructor() {
this.supportedFormats = ['jpeg', 'png', 'webp', 'avif'];
this.maxSize = 5 * 1024 * 1024; // 5MB
}
// 优化图片
async optimize(inputPath, outputPath, options = {}) {
try {
const {
width = 1200,
quality = 80,
format = 'webp',
compressionLevel = 6
} = options;
// 检查文件大小
const stats = await fs.stat(inputPath);
if (stats.size > this.maxSize) {
throw new Error('图片大小超过5MB限制');
}
// 使用sharp处理图片
let image = sharp(inputPath);
// 获取图片元数据
const metadata = await image.metadata();
// 如果图片宽度超过限制,进行缩放
if (metadata.width > width) {
image = image.resize({
width: width,
height: Math.round(width * metadata.height / metadata.width),
fit: 'inside',
withoutEnlargement: true
});
}
// 根据格式处理
switch (format) {
case 'webp':
image = image.webp({ quality, effort: 6 });
break;
case 'avif':
image = image.avif({ quality, effort: 6 });
break;
case 'jpeg':
image = image.jpeg({ quality, mozjpeg: true });
break;
case 'png':
image = image.png({ compressionLevel, progressive: true });
break;
}
// 确保输出目录存在
const outputDir = path.dirname(outputPath);
await fs.mkdir(outputDir, { recursive: true });
// 保存优化后的图片
await image.toFile(outputPath);
// 获取优化后的文件信息
const optimizedStats = await fs.stat(outputPath);
return {
success: true,
originalSize: stats.size,
optimizedSize: optimizedStats.size,
savings: ((stats.size - optimizedStats.size) / stats.size * 100).toFixed(2) + '%',
outputPath,
format
};
} catch (error) {
console.error('图片优化失败:', error);
throw error;
}
}
// 批量优化
async optimizeBatch(filePaths, options = {}) {
const results = [];
for (const filePath of filePaths) {
try {
const outputDir = path.join(path.dirname(filePath), 'optimized');
const outputPath = path.join(
outputDir,
path.basename(filePath, path.extname(filePath)) + '.webp'
);
const result = await this.optimize(filePath, outputPath, options);
results.push(result);
} catch (error) {
results.push({ success: false, error: error.message, filePath });
}
}
return results;
}
// 生成不同尺寸的图片
async generateResponsiveImages(inputPath, outputDir, sizes = [300, 600, 1200]) {
const results = [];
for (const width of sizes) {
const outputPath = path.join(
outputDir,
`${path.basename(inputPath, path.extname(inputPath))}-${width}w.webp`
);
try {
const result = await this.optimize(inputPath, outputPath, {
width,
quality: 75,
format: 'webp'
});
results.push({ ...result, width });
} catch (error) {
console.error(`生成${width}px图片失败:`, error);
}
}
return results;
}
}
module.exports = new ImageOptimizer();
8.3 前端性能优化
// components/LazyImage.jsx
import React, { useState, useEffect, useRef } from 'react';
const LazyImage = ({
src,
alt,
className,
placeholder = '/images/placeholder.svg',
...props
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const imageRef = useRef(null);
useEffect(() => {
// 如果浏览器不支持IntersectionObserver,直接加载
if (!('IntersectionObserver' in window)) {
setIsVisible(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
});
},
{
rootMargin: '50px', // 提前50px开始加载
threshold: 0.01
}
);
if (imageRef.current) {
observer.observe(imageRef.current);
}
return () => {
if (observer && imageRef.current) {
observer.unobserve(imageRef.current);
}
};
}, []);
const handleLoad = () => {
setIsLoaded(true);
};
return (
<div
ref={imageRef}
className={`${className} relative overflow-hidden`}
style={{ aspectRatio: props.width && props.height ? `${props.width}/${props.height}` : 'auto' }}
>
{/* 占位符 */}
{!isLoaded && (
<img
src={placeholder}
alt={alt}
className="absolute inset-0 w-full h-full object-cover blur-sm"
aria-hidden="true"
/>
)}
{/* 实际图片 */}
{isVisible && (
<img
src={src}
alt={alt}
className={`${className} transition-opacity duration-300 ${
isLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleLoad}
loading="lazy"
{...props}
/>
)}
</div>
);
};
export default LazyImage;
9. 监控与日志
9.1 日志系统
// utils/logger.js
const winston = require('winston');
const path = require('path');
// 自定义格式
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.printf(({ timestamp, level, message, stack, ...meta }) => {
const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : '';
return `${timestamp} [${level.toUpperCase()}]: ${stack || message} ${metaStr}`;
})
);
// 创建日志实例
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: logFormat,
transports: [
// 错误日志
new winston.transports.File({
filename: path.join(__dirname, '../../logs/error.log'),
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5
}),
// 所有日志
new winston.transports.File({
filename: path.join(__dirname, '../../logs/combined.log'),
maxsize: 5242880,
maxFiles: 5
})
],
exceptionHandlers: [
new winston.transports.File({
filename: path.join(__dirname, '../../logs/exceptions.log')
})
],
rejectionHandlers: [
new winston.transports.File({
filename: path.join(__dirname, '../../logs/rejections.log')
})
]
});
// 开发环境添加控制台输出
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
module.exports = logger;
9.2 性能监控
// middleware/performance.js
const promClient = require('prom-client');
const responseTime = require('response-time');
// 创建指标收集器
const register = new promClient.Registry();
// 默认指标
promClient.collectDefaultMetrics({ register });
// 自定义指标
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5]
});
const activeRequests = new promClient.Gauge({
name: 'http_active_requests',
help: 'Number of active HTTP requests'
});
const dbQueryDuration = new promClient.Histogram({
name: 'db_query_duration_seconds',
help: 'Duration of database queries in seconds',
labelNames: ['operation', 'collection'],
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5]
});
register.registerMetric(httpRequestDuration);
register.registerMetric(activeRequests);
register.registerMetric(dbQueryDuration);
// 性能监控中间件
const performanceMonitor = {
// HTTP请求监控
httpMonitor: responseTime((req, res, time) => {
const route = req.route ? req.route.path : req.path;
httpRequestDuration
.labels(req.method, route, res.statusCode)
.observe(time / 1000); // 转换为秒
}),
// 活跃请求计数
requestCounter: (req, res, next) => {
activeRequests.inc();
res.on('finish', () => {
activeRequests.dec();
});
next();
},
// 数据库查询监控
monitorQuery: async (operation, collection, query) => {
const start = Date.now();
try {
const result = await query();
const duration = (Date.now() - start) / 1000;
dbQueryDuration
.labels(operation, collection)
.observe(duration);
return result;
} catch (error) {
throw error;
}
},
// 指标端点
metricsEndpoint: async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
}
};
module.exports = performanceMonitor;
10. 测试策略
10.1 单元测试示例
// tests/unit/postController.test.js
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const Post = require('../../models/Post');
const User = require('../../models/User');
const { createPost, getPosts, getPostBySlug } = require('../../controllers/postController');
// 测试数据
const testUser = {
username: 'testuser',
email: 'test@example.com',
password: 'password123',
role: 'user'
};
const testPost = {
title: '测试文章标题',
content: '这是测试文章的内容,用于测试各种功能。',
excerpt: '测试摘要',
tags: ['测试', '示例'],
status: 'published'
};
describe('Post Controller', () => {
let mongoServer;
let user;
// 在所有测试开始前启动内存数据库
beforeAll(async () => {
mongoServer = new MongoMemoryServer();
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
// 每个测试前创建用户
beforeEach(async () => {
user = await User.create(testUser);
});
// 清理数据
afterEach(async () => {
await Post.deleteMany({});
await User.deleteMany({});
});
// 断开数据库连接
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe('createPost', () => {
it('应该成功创建文章', async () => {
const req = {
user: { _id: user._id, role: 'user' },
body: testPost
};
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
await createPost(req, res);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
message: '文章创建成功',
data: expect.objectContaining({
title: testPost.title,
slug: expect.any(String),
author: user._id
})
})
);
});
it('应该拒绝没有标题的文章', async () => {
const req = {
user: { _id: user._id },
body: { content: '内容' }
};
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
await createPost(req, res);
expect(res.status).toHaveBeenCalledWith(400);
});
});
describe('getPosts', () => {
beforeEach(async () => {
// 创建测试文章
await Post.create([
{ ...testPost, author: user._id, slug: 'test-post-1' },
{ ...testPost, author: user._id, slug: 'test-post-2', status: 'draft' }
]);
});
it('应该返回已发布的文章', async () => {
const req = {
user: null,
query: { page: 1, limit: 10 }
};
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
await getPosts(req, res);
expect(res.status).toHaveBeenCalledWith(200);
const responseData = res.json.mock.calls[0][0];
expect(responseData.data.posts.length).toBe(1);
expect(responseData.data.posts[0].status).toBe('published');
});
it('应该支持分页', async () => {
const req = {
user: null,
query: { page: 1, limit: 1 }
};
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
await getPosts(req, res);
const responseData = res.json.mock.calls[0][0];
expect(responseData.data.pagination.totalPages).toBe(1);
expect(responseData.data.posts.length).toBe(1);
});
});
describe('getPostBySlug', () => {
it('应该根据slug返回文章', async () => {
const post = await Post.create({
...testPost,
author: user._id,
slug: 'unique-slug-test'
});
const req = {
params: { slug: 'unique-slug-test' }
};
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
await getPostBySlug(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
data: expect.objectContaining({
slug: 'unique-slug-test'
})
})
);
});
it('应该返回404当文章不存在', async () => {
const req = {
params: { slug: '不存在的-slug' }
};
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
await getPostBySlug(req, res);
expect(res.status).toHaveBeenCalledWith(404);
});
});
});
10.2 集成测试
// tests/integration/auth.test.js
const request = require('supertest');
const app = require('../../app');
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
describe('Authentication Integration Tests', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoMemoryServer();
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe('POST /api/auth/register', () => {
it('应该成功注册新用户', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
username: 'newuser',
email: 'newuser@example.com',
password: 'password123'
});
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('token');
});
it('应该拒绝重复的邮箱', async () => {
// 先注册一个用户
await request(app)
.post('/api/auth/register')
.send({
username: 'user1',
email: 'duplicate@example.com',
password: 'password123'
});
// 尝试重复注册
const response = await request(app)
.post('/api/auth/register')
.send({
username: 'user2',
email: 'duplicate@example.com',
password: 'password123'
});
expect(response.status).toBe(409);
expect(response.body.success).toBe(false);
});
});
describe('POST /api/auth/login', () => {
beforeEach(async () => {
// 注册测试用户
await request(app)
.post('/api/auth/register')
.send({
username: 'loginuser',
email: 'login@example.com',
password: 'correctpassword'
});
});
it('应该使用正确凭据登录', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'login@example.com',
password: 'correctpassword'
});
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.token).toBeDefined();
});
it('应该拒绝错误密码', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'login@example.com',
password: 'wrongpassword'
});
expect(response.status).toBe(401);
expect(response.body.success).toBe(false);
});
});
});
11. 部署脚本
11.1 自动化部署脚本
#!/bin/bash
# deploy.sh
set -e # 遇到错误立即退出
# 颜色输出
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}开始部署博客系统...${NC}"
# 检查必要的环境变量
if [ -z "$SERVER_IP" ]; then
echo -e "${RED}错误: 未设置 SERVER_IP 环境变量${NC}"
exit 1
fi
if [ -z "$DEPLOY_PATH" ]; then
echo -e "${RED}错误: 未设置 DEPLOY_PATH 环境变量${NC}"
exit 1
fi
# 步骤1: 代码更新
echo -e "${YELLOW}步骤1: 更新代码...${NC}"
git pull origin main
# 步骤2: 运行测试
echo -e "${YELLOW}步骤2: 运行测试...${NC}"
npm test
if [ $? -ne 0 ]; then
echo -e "${RED}测试失败,停止部署${NC}"
exit 1
fi
# 步骤3: 构建应用
echo -e "${YELLOW}步骤3: 构建应用...${NC}"
npm run build
# 步骤4: 打包
echo -e "${YELLOW}步骤4: 打包...${NC}"
tar -czf blog-deploy.tar.gz \
--exclude='*.tar.gz' \
--exclude='node_modules' \
--exclude='tests' \
--exclude='.git' \
--exclude='logs' \
dist package.json docker-compose.yml nginx.conf .env.example
# 步骤5: 传输文件
echo -e "${YELLOW}步骤5: 传输文件到服务器...${NC}"
scp blog-deploy.tar.gz ${SERVER_IP}:${DEPLOY_PATH}/
# 步骤6: 远程部署
echo -e "${YELLOW}步骤6: 执行远程部署...${NC}"
ssh ${SERVER_IP} << EOF
set -e
cd ${DEPLOY_PATH}
# 解压
echo "解压文件..."
tar -xzf blog-deploy.tar.gz
# 停止服务
echo "停止服务..."
docker-compose down
# 清理旧镜像
echo "清理旧镜像..."
docker system prune -af
# 启动服务
echo "启动服务..."
docker-compose up -d --build
# 等待服务启动
echo "等待服务启动..."
sleep 10
# 检查服务状态
echo "检查服务状态..."
docker-compose ps
# 查看日志
echo "最近日志:"
docker-compose logs --tail=20
# 清理
rm -f blog-deploy.tar.gz
echo "部署完成!"
EOF
# 清理本地文件
rm -f blog-deploy.tar.gz
echo -e "${GREEN}部署成功!${NC}"
11.2 备份脚本
#!/bin/bash
# backup.sh
set -e
# 配置
BACKUP_DIR="/backup"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="blog_backup_${DATE}"
RETENTION_DAYS=7
# 创建备份目录
mkdir -p ${BACKUP_DIR}
echo "开始备份: ${BACKUP_NAME}"
# MongoDB备份
if [ -n "${MONGO_USER}" ] && [ -n "${MONGO_PASSWORD}" ]; then
echo "备份MongoDB..."
mongodump \
--host mongodb \
--port 27017 \
--username ${MONGO_USER} \
--password ${MONGO_PASSWORD} \
--authenticationDatabase admin \
--db blog \
--out ${BACKUP_DIR}/${BACKUP_NAME}/mongodb/
# 压缩
tar -czf ${BACKUP_DIR}/${BACKUP_NAME}_mongodb.tar.gz -C ${BACKUP_DIR}/${BACKUP_NAME} mongodb/
rm -rf ${BACKUP_DIR}/${BACKUP_NAME}/mongodb/
fi
# 备份上传文件
if [ -d "/app/public/uploads" ]; then
echo "备份上传文件..."
tar -czf ${BACKUP_DIR}/${BACKUP_NAME}_uploads.tar.gz -C /app/public uploads/
fi
# 备份配置文件
if [ -f "/app/.env" ]; then
echo "备份配置文件..."
cp /app/.env ${BACKUP_DIR}/${BACKUP_NAME}_env.bak
tar -czf ${BACKUP_DIR}/${BACKUP_NAME}_config.tar.gz ${BACKUP_DIR}/${BACKUP_NAME}_env.bak
rm -f ${BACKUP_DIR}/${BACKUP_NAME}_env.bak
fi
# 清理旧备份
echo "清理旧备份(保留${RETENTION_DAYS}天)..."
find ${BACKUP_DIR} -name "*.tar.gz" -mtime +${RETENTION_DAYS} -delete
# 生成备份报告
cat > ${BACKUP_DIR}/${BACKUP_NAME}_report.txt << EOF
备份时间: $(date)
备份名称: ${BACKUP_NAME}
备份文件:
$(ls -lh ${BACKUP_DIR}/${BACKUP_NAME}_*.tar.gz 2>/dev/null || echo "无备份文件")
总大小: $(du -sh ${BACKUP_DIR} | cut -f1)
EOF
echo "备份完成: ${BACKUP_NAME}"
12. 监控与告警
12.1 健康检查端点
// routes/health.js
const express = require('express');
const router = express.Router();
const mongoose = require('mongoose');
const redis = require('redis');
const os = require('os');
// 数据库连接检查
const checkDatabase = async () => {
try {
await mongoose.connection.db.admin().ping();
return { status: 'ok', latency: 0 };
} catch (error) {
throw new Error(`数据库连接失败: ${error.message}`);
}
};
// Redis连接检查
const checkRedis = async () => {
return new Promise((resolve, reject) => {
const client = redis.createClient({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD
});
const startTime = Date.now();
client.on('ready', () => {
const latency = Date.now() - startTime;
client.quit();
resolve({ status: 'ok', latency });
});
client.on('error', (err) => {
client.quit();
reject(new Error(`Redis连接失败: ${err.message}`));
});
// 超时处理
setTimeout(() => {
client.quit();
reject(new Error('Redis连接超时'));
}, 5000);
});
};
// 系统资源检查
const checkSystem = () => {
const loadAvg = os.loadavg();
const memory = process.memoryUsage();
return {
uptime: process.uptime(),
memory: {
used: Math.round(memory.heapUsed / 1024 / 1024) + 'MB',
total: Math.round(memory.heapTotal / 1024 / 1024) + 'MB'
},
cpuLoad: {
'1min': loadAvg[0].toFixed(2),
'5min': loadAvg[1].toFixed(2),
'15min': loadAvg[2].toFixed(2)
}
};
};
// 健康检查路由
router.get('/health', async (req, res) => {
const checks = {
timestamp: new Date().toISOString(),
uptime: process.uptime(),
status: 'healthy'
};
try {
// 并行检查
const [db, redis, system] = await Promise.all([
checkDatabase().catch(err => ({ status: 'error', message: err.message })),
checkRedis().catch(err => ({ status: 'error', message: err.message })),
Promise.resolve(checkSystem())
]);
checks.database = db;
checks.redis = redis;
checks.system = system;
// 确定整体状态
if (db.status === 'error' || redis.status === 'error') {
checks.status = 'unhealthy';
return res.status(503).json(checks);
}
// 警告级别(高负载)
if (system.cpuLoad['1min'] > 5 || system.memory.used > '500MB') {
checks.status = 'degraded';
return res.status(200).json(checks);
}
return res.status(200).json(checks);
} catch (error) {
checks.status = 'error';
checks.error = error.message;
return res.status(500).json(checks);
}
});
// 详细信息端点
router.get('/health/details', async (req, res) => {
try {
const details = {
environment: process.env.NODE_ENV,
version: process.env.npm_package_version,
nodeVersion: process.version,
platform: process.platform,
arch: process.arch,
pid: process.pid,
memory: process.memoryUsage(),
uptime: process.uptime(),
database: {
name: mongoose.connection.name,
host: mongoose.connection.host,
port: mongoose.connection.port,
state: mongoose.connection.readyState
},
redis: {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
}
};
res.json(details);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
13. 总结
通过本文的详细指导,您已经了解了如何从零开始构建一个功能完善的博客系统。我们涵盖了:
核心要点回顾:
- 技术栈选择:使用现代Web技术(Node.js + React + MongoDB + Redis)
- 数据库设计:精心设计的数据模型和索引优化
- API开发:完整的RESTful API实现,包括认证、授权和缓存
- 前端实现:React组件化开发和状态管理
- 高级功能:评论系统、图片优化、性能监控
- 安全实践:输入验证、XSS防护、限流等安全措施
- 部署方案:Docker容器化、Nginx配置、自动化部署
- 性能优化:数据库索引、缓存策略、图片压缩
- 监控告警:日志系统、健康检查、性能指标
最佳实践总结:
- 代码质量:使用ESLint、Prettier保持代码风格一致
- 测试覆盖:编写单元测试和集成测试,确保代码可靠性
- 文档完善:API文档、部署文档、运维手册
- 持续集成:自动化测试和部署流程
- 安全第一:始终考虑安全因素,定期更新依赖
扩展建议:
功能扩展:
- 添加RSS订阅功能
- 实现全文搜索(Elasticsearch)
- 集成第三方登录(OAuth2)
- 添加文章收藏和阅读历史
性能优化:
- 实现服务端渲染(SSR)
- 使用CDN加速静态资源
- 添加GraphQL API
- 实现微服务架构
运维改进:
- 配置CI/CD流水线
- 实现蓝绿部署
- 添加应用性能管理(APM)
- 建立完善的监控体系
这个博客系统不仅功能完整,而且具有良好的可扩展性和可维护性。您可以根据实际需求进行定制和扩展,构建属于自己的博客平台。祝您开发顺利!
