引言

在当今数字化时代,拥有一个个人博客已成为分享知识、建立个人品牌和连接志同道合者的重要方式。本文将详细介绍如何使用现代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. 总结

通过本文的详细指导,您已经了解了如何从零开始构建一个功能完善的博客系统。我们涵盖了:

核心要点回顾:

  1. 技术栈选择:使用现代Web技术(Node.js + React + MongoDB + Redis)
  2. 数据库设计:精心设计的数据模型和索引优化
  3. API开发:完整的RESTful API实现,包括认证、授权和缓存
  4. 前端实现:React组件化开发和状态管理
  5. 高级功能:评论系统、图片优化、性能监控
  6. 安全实践:输入验证、XSS防护、限流等安全措施
  7. 部署方案:Docker容器化、Nginx配置、自动化部署
  8. 性能优化:数据库索引、缓存策略、图片压缩
  9. 监控告警:日志系统、健康检查、性能指标

最佳实践总结:

  • 代码质量:使用ESLint、Prettier保持代码风格一致
  • 测试覆盖:编写单元测试和集成测试,确保代码可靠性
  • 文档完善:API文档、部署文档、运维手册
  • 持续集成:自动化测试和部署流程
  • 安全第一:始终考虑安全因素,定期更新依赖

扩展建议:

  1. 功能扩展

    • 添加RSS订阅功能
    • 实现全文搜索(Elasticsearch)
    • 集成第三方登录(OAuth2)
    • 添加文章收藏和阅读历史
  2. 性能优化

    • 实现服务端渲染(SSR)
    • 使用CDN加速静态资源
    • 添加GraphQL API
    • 实现微服务架构
  3. 运维改进

    • 配置CI/CD流水线
    • 实现蓝绿部署
    • 添加应用性能管理(APM)
    • 建立完善的监控体系

这个博客系统不仅功能完整,而且具有良好的可扩展性和可维护性。您可以根据实际需求进行定制和扩展,构建属于自己的博客平台。祝您开发顺利!