博客系统 #

一、项目概述 #

1.1 功能需求 #

  • 文章管理(CRUD)
  • 分类和标签
  • 评论系统
  • 用户管理
  • 搜索功能

1.2 项目结构 #

text
blog-system/
├── controllers/
│   ├── articleController.js
│   ├── categoryController.js
│   ├── commentController.js
│   └── tagController.js
├── models/
│   ├── Article.js
│   ├── Category.js
│   ├── Comment.js
│   └── Tag.js
├── routes/
│   ├── articleRoutes.js
│   ├── categoryRoutes.js
│   └── commentRoutes.js
├── services/
│   └── searchService.js
├── views/
│   ├── layouts/
│   ├── partials/
│   └── pages/
└── public/
    ├── css/
    └── js/

二、数据模型 #

2.1 models/Article.js #

javascript
const mongoose = require('mongoose');

const articleSchema = new mongoose.Schema({
    title: {
        type: String,
        required: [true, '请输入标题'],
        trim: true,
        maxlength: [200, '标题不能超过200个字符']
    },
    slug: {
        type: String,
        unique: true,
        lowercase: true
    },
    content: {
        type: String,
        required: [true, '请输入内容']
    },
    excerpt: {
        type: String,
        maxlength: [500, '摘要不能超过500个字符']
    },
    cover: String,
    author: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User',
        required: true
    },
    category: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Category'
    },
    tags: [{
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Tag'
    }],
    status: {
        type: String,
        enum: ['draft', 'published', 'archived'],
        default: 'draft'
    },
    viewCount: {
        type: Number,
        default: 0
    },
    likeCount: {
        type: Number,
        default: 0
    },
    commentCount: {
        type: Number,
        default: 0
    },
    isTop: {
        type: Boolean,
        default: false
    },
    publishedAt: Date
}, {
    timestamps: true
});

articleSchema.pre('save', function(next) {
    if (this.isModified('title')) {
        this.slug = this.title
            .toLowerCase()
            .replace(/[^\w\s-]/g, '')
            .replace(/\s+/g, '-')
            .substring(0, 100);
    }
    
    if (this.status === 'published' && !this.publishedAt) {
        this.publishedAt = Date.now();
    }
    
    if (!this.excerpt && this.content) {
        this.excerpt = this.content
            .replace(/<[^>]*>/g, '')
            .substring(0, 200);
    }
    
    next();
});

articleSchema.index({ title: 'text', content: 'text' });
articleSchema.index({ slug: 1 });
articleSchema.index({ status: 1, publishedAt: -1 });

articleSchema.methods.incrementViewCount = function() {
    return this.updateOne({ $inc: { viewCount: 1 } });
};

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

2.2 models/Category.js #

javascript
const mongoose = require('mongoose');

const categorySchema = new mongoose.Schema({
    name: {
        type: String,
        required: true,
        unique: true,
        trim: true
    },
    slug: {
        type: String,
        unique: true,
        lowercase: true
    },
    description: String,
    parent: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Category'
    },
    order: {
        type: Number,
        default: 0
    },
    articleCount: {
        type: Number,
        default: 0
    }
}, {
    timestamps: true
});

categorySchema.pre('save', function(next) {
    if (this.isModified('name')) {
        this.slug = this.name
            .toLowerCase()
            .replace(/[^\w\s-]/g, '')
            .replace(/\s+/g, '-');
    }
    next();
});

module.exports = mongoose.model('Category', categorySchema);

2.3 models/Comment.js #

javascript
const mongoose = require('mongoose');

const commentSchema = new mongoose.Schema({
    content: {
        type: String,
        required: [true, '请输入评论内容'],
        maxlength: [1000, '评论不能超过1000个字符']
    },
    article: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Article',
        required: true
    },
    author: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User',
        required: true
    },
    parent: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Comment'
    },
    replies: [{
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Comment'
    }],
    likeCount: {
        type: Number,
        default: 0
    },
    isApproved: {
        type: Boolean,
        default: true
    }
}, {
    timestamps: true
});

commentSchema.post('save', async function() {
    await this.model('Article').findByIdAndUpdate(
        this.article,
        { $inc: { commentCount: 1 } }
    );
});

commentSchema.post('remove', async function() {
    await this.model('Article').findByIdAndUpdate(
        this.article,
        { $inc: { commentCount: -1 } }
    );
});

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

三、文章控制器 #

3.1 controllers/articleController.js #

javascript
const Article = require('../models/Article');
const Category = require('../models/Category');
const Tag = require('../models/Tag');
const asyncHandler = require('../utils/asyncHandler');

exports.getAll = asyncHandler(async (req, res) => {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
    const skip = (page - 1) * limit;
    
    const query = { status: 'published' };
    
    if (req.query.category) {
        query.category = req.query.category;
    }
    
    if (req.query.tag) {
        query.tags = req.query.tag;
    }
    
    if (req.query.search) {
        query.$text = { $search: req.query.search };
    }
    
    const [articles, total] = await Promise.all([
        Article.find(query)
            .populate('author', 'name avatar')
            .populate('category', 'name slug')
            .populate('tags', 'name slug')
            .sort({ isTop: -1, publishedAt: -1 })
            .skip(skip)
            .limit(limit),
        Article.countDocuments(query)
    ]);
    
    res.json({
        success: true,
        data: articles,
        pagination: {
            page,
            limit,
            total,
            totalPages: Math.ceil(total / limit)
        }
    });
});

exports.getBySlug = asyncHandler(async (req, res) => {
    const article = await Article.findOne({ slug: req.params.slug })
        .populate('author', 'name avatar bio')
        .populate('category', 'name slug')
        .populate('tags', 'name slug');
    
    if (!article) {
        return res.status(404).json({ error: '文章不存在' });
    }
    
    await article.incrementViewCount();
    
    const relatedArticles = await Article.find({
        _id: { $ne: article._id },
        category: article.category,
        status: 'published'
    })
        .limit(5)
        .select('title slug cover publishedAt');
    
    res.json({
        success: true,
        data: { article, relatedArticles }
    });
});

exports.create = asyncHandler(async (req, res) => {
    const article = await Article.create({
        ...req.body,
        author: req.user.id
    });
    
    if (article.category) {
        await Category.findByIdAndUpdate(article.category, {
            $inc: { articleCount: 1 }
        });
    }
    
    res.status(201).json({
        success: true,
        data: article,
        message: '文章创建成功'
    });
});

exports.update = asyncHandler(async (req, res) => {
    let article = await Article.findById(req.params.id);
    
    if (!article) {
        return res.status(404).json({ error: '文章不存在' });
    }
    
    if (article.author.toString() !== req.user.id && req.user.role !== 'admin') {
        return res.status(403).json({ error: '无权修改此文章' });
    }
    
    article = await Article.findByIdAndUpdate(
        req.params.id,
        { $set: req.body },
        { new: true, runValidators: true }
    );
    
    res.json({
        success: true,
        data: article,
        message: '文章更新成功'
    });
});

exports.delete = asyncHandler(async (req, res) => {
    const article = await Article.findById(req.params.id);
    
    if (!article) {
        return res.status(404).json({ error: '文章不存在' });
    }
    
    if (article.author.toString() !== req.user.id && req.user.role !== 'admin') {
        return res.status(403).json({ error: '无权删除此文章' });
    }
    
    await article.deleteOne();
    
    res.json({
        success: true,
        message: '文章删除成功'
    });
});

exports.getMyArticles = asyncHandler(async (req, res) => {
    const articles = await Article.find({ author: req.user.id })
        .populate('category', 'name')
        .sort({ createdAt: -1 });
    
    res.json({
        success: true,
        data: articles
    });
});

四、评论控制器 #

4.1 controllers/commentController.js #

javascript
const Comment = require('../models/Comment');
const Article = require('../models/Article');
const asyncHandler = require('../utils/asyncHandler');

exports.getByArticle = asyncHandler(async (req, res) => {
    const comments = await Comment.find({
        article: req.params.articleId,
        isApproved: true,
        parent: null
    })
        .populate('author', 'name avatar')
        .populate({
            path: 'replies',
            populate: { path: 'author', select: 'name avatar' }
        })
        .sort({ createdAt: -1 });
    
    res.json({
        success: true,
        data: comments
    });
});

exports.create = asyncHandler(async (req, res) => {
    const article = await Article.findById(req.params.articleId);
    
    if (!article) {
        return res.status(404).json({ error: '文章不存在' });
    }
    
    const comment = await Comment.create({
        content: req.body.content,
        article: req.params.articleId,
        author: req.user.id,
        parent: req.body.parentId || null
    });
    
    if (req.body.parentId) {
        await Comment.findByIdAndUpdate(req.body.parentId, {
            $push: { replies: comment._id }
        });
    }
    
    const populatedComment = await Comment.findById(comment._id)
        .populate('author', 'name avatar');
    
    res.status(201).json({
        success: true,
        data: populatedComment,
        message: '评论发表成功'
    });
});

exports.delete = asyncHandler(async (req, res) => {
    const comment = await Comment.findById(req.params.id);
    
    if (!comment) {
        return res.status(404).json({ error: '评论不存在' });
    }
    
    if (comment.author.toString() !== req.user.id && req.user.role !== 'admin') {
        return res.status(403).json({ error: '无权删除此评论' });
    }
    
    await comment.deleteOne();
    
    res.json({
        success: true,
        message: '评论删除成功'
    });
});

五、搜索服务 #

5.1 services/searchService.js #

javascript
const Article = require('../models/Article');

const searchService = {
    async search(keyword, options = {}) {
        const page = parseInt(options.page) || 1;
        const limit = parseInt(options.limit) || 10;
        const skip = (page - 1) * limit;
        
        const query = {
            status: 'published',
            $text: { $search: keyword }
        };
        
        const [articles, total] = await Promise.all([
            Article.find(query, { score: { $meta: 'textScore' } })
                .populate('author', 'name')
                .populate('category', 'name slug')
                .sort({ score: { $meta: 'textScore' } })
                .skip(skip)
                .limit(limit),
            Article.countDocuments(query)
        ]);
        
        return {
            articles,
            pagination: {
                page,
                limit,
                total,
                totalPages: Math.ceil(total / limit)
            }
        };
    },
    
    async getHotKeywords(limit = 10) {
        const articles = await Article.aggregate([
            { $match: { status: 'published' } },
            { $unwind: '$tags' },
            { $group: { _id: '$tags', count: { $sum: 1 } } },
            { $sort: { count: -1 } },
            { $limit: limit }
        ]);
        
        return articles;
    },
    
    async getArchive() {
        const articles = await Article.aggregate([
            { $match: { status: 'published' } },
            {
                $group: {
                    _id: {
                        year: { $year: '$publishedAt' },
                        month: { $month: '$publishedAt' }
                    },
                    count: { $sum: 1 },
                    articles: {
                        $push: {
                            title: '$title',
                            slug: '$slug',
                            publishedAt: '$publishedAt'
                        }
                    }
                }
            },
            { $sort: { '_id.year': -1, '_id.month': -1 } }
        ]);
        
        return articles;
    }
};

module.exports = searchService;

六、路由定义 #

6.1 routes/articleRoutes.js #

javascript
const express = require('express');
const router = express.Router();
const articleController = require('../controllers/articleController');
const { auth, authorize } = require('../middlewares/auth');

router.get('/', articleController.getAll);
router.get('/my', auth, articleController.getMyArticles);
router.get('/:slug', articleController.getBySlug);

router.post('/', auth, articleController.create);
router.put('/:id', auth, articleController.update);
router.delete('/:id', auth, articleController.delete);

module.exports = router;

6.2 routes/commentRoutes.js #

javascript
const express = require('express');
const router = express.Router();
const commentController = require('../controllers/commentController');
const { auth } = require('../middlewares/auth');

router.get('/article/:articleId', commentController.getByArticle);
router.post('/article/:articleId', auth, commentController.create);
router.delete('/:id', auth, commentController.delete);

module.exports = router;

七、总结 #

博客系统要点:

模块 功能
文章 CRUD、分类、标签
评论 发表、回复、删除
搜索 全文搜索、归档
用户 认证、权限

下一步,让我们学习部署上线!

最后更新:2026-03-28