跳到主要内容

Sequelize 实战项目 - 构建完整的博客系统

· 阅读需 13 分钟
一介布衣
全栈开发者

发布时间:2024-04-15
作者:一介布衣
标签:Sequelize, 实战项目, 博客系统, 全栈开发

前言

今天咱们来做一个完整的实战项目 - 用 Sequelize 构建一个功能完整的博客系统。说实话,通过实际项目来学习技术是最有效的方法,能让你把之前学到的知识点串联起来。

我记得刚开始学 Sequelize 的时候,看了很多教程,但总感觉缺少实战经验。后来自己动手做了几个项目,才真正理解了 ORM 在实际开发中的应用。

今天我就带大家从零开始,构建一个包含用户管理、文章发布、评论系统、标签分类等功能的完整博客系统。

项目需求分析

功能需求

用户模块

  • 用户注册、登录、登出
  • 用户资料管理
  • 密码修改
  • 头像上传

文章模块

  • 文章发布、编辑、删除
  • 文章分类管理
  • 文章标签系统
  • 文章状态管理(草稿、发布、归档)
  • 文章搜索

评论模块

  • 文章评论
  • 评论回复
  • 评论管理

管理模块

  • 用户管理
  • 文章管理
  • 评论管理
  • 数据统计

技术栈

  • 后端:Node.js + Express + Sequelize
  • 数据库:MySQL
  • 认证:JWT
  • 文件上传:Multer
  • API文档:Swagger
  • 测试:Jest

数据库设计

1. 用户表(Users)

// models/User.js
const { DataTypes } = require('sequelize');
const bcrypt = require('bcryptjs');

module.exports = (sequelize) => {
const User = sequelize.define('User', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
username: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true,
validate: {
len: [3, 50],
isAlphanumeric: true
}
},
email: {
type: DataTypes.STRING(100),
allowNull: false,
unique: true,
validate: {
isEmail: true
}
},
password: {
type: DataTypes.STRING(255),
allowNull: false,
validate: {
len: [6, 255]
}
},
nickname: {
type: DataTypes.STRING(50),
allowNull: true
},
avatar: {
type: DataTypes.STRING(500),
allowNull: true
},
bio: {
type: DataTypes.TEXT,
allowNull: true
},
role: {
type: DataTypes.ENUM('user', 'admin'),
defaultValue: 'user'
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'banned'),
defaultValue: 'active'
},
lastLoginAt: {
type: DataTypes.DATE,
allowNull: true
}
}, {
hooks: {
beforeCreate: async (user) => {
if (user.password) {
user.password = await bcrypt.hash(user.password, 10);
}
},
beforeUpdate: async (user) => {
if (user.changed('password')) {
user.password = await bcrypt.hash(user.password, 10);
}
}
}
});

// 实例方法
User.prototype.validatePassword = async function(password) {
return await bcrypt.compare(password, this.password);
};

User.prototype.toSafeObject = function() {
const { password, ...safeUser } = this.toJSON();
return safeUser;
};

// 关联关系
User.associate = (models) => {
User.hasMany(models.Post, { foreignKey: 'authorId', as: 'posts' });
User.hasMany(models.Comment, { foreignKey: 'authorId', as: 'comments' });
};

return User;
};

2. 分类表(Categories)

// models/Category.js
module.exports = (sequelize, DataTypes) => {
const Category = sequelize.define('Category', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
slug: {
type: DataTypes.STRING(50),
allowNull: false,
unique: true
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
color: {
type: DataTypes.STRING(7),
defaultValue: '#007bff'
},
sortOrder: {
type: DataTypes.INTEGER,
defaultValue: 0
}
});

Category.associate = (models) => {
Category.hasMany(models.Post, { foreignKey: 'categoryId', as: 'posts' });
};

return Category;
};

3. 标签表(Tags)

// models/Tag.js
module.exports = (sequelize, DataTypes) => {
const Tag = sequelize.define('Tag', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING(30),
allowNull: false,
unique: true
},
slug: {
type: DataTypes.STRING(30),
allowNull: false,
unique: true
},
color: {
type: DataTypes.STRING(7),
defaultValue: '#6c757d'
}
});

Tag.associate = (models) => {
Tag.belongsToMany(models.Post, {
through: 'PostTags',
foreignKey: 'tagId',
otherKey: 'postId',
as: 'posts'
});
};

return Tag;
};

4. 文章表(Posts)

// models/Post.js
module.exports = (sequelize, DataTypes) => {
const Post = sequelize.define('Post', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
title: {
type: DataTypes.STRING(200),
allowNull: false,
validate: {
len: [5, 200]
}
},
slug: {
type: DataTypes.STRING(200),
allowNull: false,
unique: true
},
excerpt: {
type: DataTypes.TEXT,
allowNull: true
},
content: {
type: DataTypes.TEXT('long'),
allowNull: false
},
featuredImage: {
type: DataTypes.STRING(500),
allowNull: true
},
status: {
type: DataTypes.ENUM('draft', 'published', 'archived'),
defaultValue: 'draft'
},
publishedAt: {
type: DataTypes.DATE,
allowNull: true
},
viewCount: {
type: DataTypes.INTEGER,
defaultValue: 0
},
likeCount: {
type: DataTypes.INTEGER,
defaultValue: 0
},
commentCount: {
type: DataTypes.INTEGER,
defaultValue: 0
},
authorId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Users',
key: 'id'
}
},
categoryId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Categories',
key: 'id'
}
}
}, {
hooks: {
beforeCreate: (post) => {
if (post.status === 'published' && !post.publishedAt) {
post.publishedAt = new Date();
}
},
beforeUpdate: (post) => {
if (post.changed('status') && post.status === 'published' && !post.publishedAt) {
post.publishedAt = new Date();
}
}
}
});

// 实例方法
Post.prototype.incrementViewCount = async function() {
await this.increment('viewCount');
};

Post.prototype.isPublished = function() {
return this.status === 'published';
};

// 关联关系
Post.associate = (models) => {
Post.belongsTo(models.User, { foreignKey: 'authorId', as: 'author' });
Post.belongsTo(models.Category, { foreignKey: 'categoryId', as: 'category' });
Post.belongsToMany(models.Tag, {
through: 'PostTags',
foreignKey: 'postId',
otherKey: 'tagId',
as: 'tags'
});
Post.hasMany(models.Comment, { foreignKey: 'postId', as: 'comments' });
};

return Post;
};

5. 评论表(Comments)

// models/Comment.js
module.exports = (sequelize, DataTypes) => {
const Comment = sequelize.define('Comment', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
content: {
type: DataTypes.TEXT,
allowNull: false,
validate: {
len: [1, 1000]
}
},
status: {
type: DataTypes.ENUM('pending', 'approved', 'rejected'),
defaultValue: 'pending'
},
authorId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Users',
key: 'id'
}
},
postId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Posts',
key: 'id'
}
},
parentId: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'Comments',
key: 'id'
}
}
});

Comment.associate = (models) => {
Comment.belongsTo(models.User, { foreignKey: 'authorId', as: 'author' });
Comment.belongsTo(models.Post, { foreignKey: 'postId', as: 'post' });
Comment.belongsTo(models.Comment, { foreignKey: 'parentId', as: 'parent' });
Comment.hasMany(models.Comment, { foreignKey: 'parentId', as: 'replies' });
};

return Comment;
};

核心服务实现

1. 用户服务

// services/UserService.js
const { User } = require('../models');
const jwt = require('jsonwebtoken');
const { ValidationError, NotFoundError } = require('../utils/errors');

class UserService {
static async register(userData) {
const { username, email, password, nickname } = userData;

// 检查用户名和邮箱是否已存在
const existingUser = await User.findOne({
where: {
[Op.or]: [{ username }, { email }]
}
});

if (existingUser) {
if (existingUser.username === username) {
throw new ValidationError('用户名已存在');
}
if (existingUser.email === email) {
throw new ValidationError('邮箱已被注册');
}
}

const user = await User.create({
username,
email,
password,
nickname: nickname || username
});

return {
user: user.toSafeObject(),
token: this.generateToken(user.id)
};
}

static async login(email, password) {
const user = await User.findOne({ where: { email } });

if (!user) {
throw new NotFoundError('用户不存在');
}

if (user.status !== 'active') {
throw new ValidationError('账户已被禁用');
}

const isValidPassword = await user.validatePassword(password);
if (!isValidPassword) {
throw new ValidationError('密码错误');
}

// 更新最后登录时间
await user.update({ lastLoginAt: new Date() });

return {
user: user.toSafeObject(),
token: this.generateToken(user.id)
};
}

static async getUserProfile(userId) {
const user = await User.findByPk(userId, {
attributes: { exclude: ['password'] },
include: [
{
model: Post,
as: 'posts',
where: { status: 'published' },
required: false,
attributes: ['id', 'title', 'slug', 'publishedAt'],
limit: 5,
order: [['publishedAt', 'DESC']]
}
]
});

if (!user) {
throw new NotFoundError('用户不存在');
}

return user;
}

static async updateProfile(userId, updateData) {
const user = await User.findByPk(userId);
if (!user) {
throw new NotFoundError('用户不存在');
}

const allowedFields = ['nickname', 'bio', 'avatar'];
const filteredData = {};

allowedFields.forEach(field => {
if (updateData[field] !== undefined) {
filteredData[field] = updateData[field];
}
});

await user.update(filteredData);
return user.toSafeObject();
}

static generateToken(userId) {
return jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
}

static verifyToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
throw new ValidationError('无效的令牌');
}
}
}

module.exports = UserService;

2. 文章服务

// services/PostService.js
const { Post, User, Category, Tag, Comment } = require('../models');
const { Op } = require('sequelize');
const slugify = require('slugify');

class PostService {
static async createPost(authorId, postData) {
const { title, content, excerpt, categoryId, tags, status = 'draft' } = postData;

// 生成 slug
const slug = await this.generateUniqueSlug(title);

const post = await Post.create({
title,
slug,
content,
excerpt: excerpt || this.generateExcerpt(content),
categoryId,
authorId,
status
});

// 处理标签
if (tags && tags.length > 0) {
await this.attachTags(post.id, tags);
}

return await this.getPostById(post.id);
}

static async updatePost(postId, authorId, updateData) {
const post = await Post.findOne({
where: { id: postId, authorId }
});

if (!post) {
throw new NotFoundError('文章不存在或无权限');
}

const { title, content, excerpt, categoryId, tags, status } = updateData;
const updateFields = {};

if (title && title !== post.title) {
updateFields.title = title;
updateFields.slug = await this.generateUniqueSlug(title, postId);
}

if (content) updateFields.content = content;
if (excerpt) updateFields.excerpt = excerpt;
if (categoryId) updateFields.categoryId = categoryId;
if (status) updateFields.status = status;

await post.update(updateFields);

// 更新标签
if (tags) {
await this.updateTags(postId, tags);
}

return await this.getPostById(postId);
}

static async getPostById(postId, includePrivate = false) {
const whereClause = { id: postId };
if (!includePrivate) {
whereClause.status = 'published';
}

const post = await Post.findOne({
where: whereClause,
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'nickname', 'avatar']
},
{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug', 'color']
},
{
model: Tag,
as: 'tags',
attributes: ['id', 'name', 'slug', 'color'],
through: { attributes: [] }
}
]
});

if (!post) {
throw new NotFoundError('文章不存在');
}

// 增加浏览量(只对已发布的文章)
if (post.status === 'published') {
await post.incrementViewCount();
}

return post;
}

static async getPostsList(options = {}) {
const {
page = 1,
limit = 10,
categoryId,
tagId,
authorId,
search,
status = 'published'
} = options;

const offset = (page - 1) * limit;
const whereClause = { status };

// 分类筛选
if (categoryId) {
whereClause.categoryId = categoryId;
}

// 作者筛选
if (authorId) {
whereClause.authorId = authorId;
}

// 搜索
if (search) {
whereClause[Op.or] = [
{ title: { [Op.like]: `%\${search}%` } },
{ content: { [Op.like]: `%\${search}%` } }
];
}

const includeClause = [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'nickname', 'avatar']
},
{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug', 'color']
},
{
model: Tag,
as: 'tags',
attributes: ['id', 'name', 'slug', 'color'],
through: { attributes: [] }
}
];

// 标签筛选
if (tagId) {
includeClause[2].where = { id: tagId };
includeClause[2].required = true;
}

const { count, rows } = await Post.findAndCountAll({
where: whereClause,
include: includeClause,
order: [['publishedAt', 'DESC']],
limit,
offset,
distinct: true
});

return {
posts: rows,
pagination: {
total: count,
page,
limit,
pages: Math.ceil(count / limit)
}
};
}

static async deletePost(postId, authorId) {
const post = await Post.findOne({
where: { id: postId, authorId }
});

if (!post) {
throw new NotFoundError('文章不存在或无权限');
}

await post.destroy();
return true;
}

// 辅助方法
static async generateUniqueSlug(title, excludeId = null) {
let baseSlug = slugify(title, { lower: true, strict: true });
let slug = baseSlug;
let counter = 1;

while (true) {
const whereClause = { slug };
if (excludeId) {
whereClause.id = { [Op.ne]: excludeId };
}

const existingPost = await Post.findOne({ where: whereClause });
if (!existingPost) {
break;
}

slug = `\${baseSlug}-\${counter}`;
counter++;
}

return slug;
}

static generateExcerpt(content, length = 200) {
// 移除 HTML 标签
const plainText = content.replace(/<[^>]*>/g, '');
return plainText.length > length
? plainText.substring(0, length) + '...'
: plainText;
}

static async attachTags(postId, tagNames) {
const tags = await Promise.all(
tagNames.map(async (name) => {
const [tag] = await Tag.findOrCreate({
where: { name },
defaults: {
name,
slug: slugify(name, { lower: true, strict: true })
}
});
return tag;
})
);

const post = await Post.findByPk(postId);
await post.setTags(tags);
}

static async updateTags(postId, tagNames) {
await this.attachTags(postId, tagNames);
}
}

module.exports = PostService;

3. 评论服务

// services/CommentService.js
const { Comment, User, Post } = require('../models');
const { NotFoundError, ValidationError } = require('../utils/errors');

class CommentService {
static async createComment(authorId, commentData) {
const { content, postId, parentId } = commentData;

// 验证文章是否存在
const post = await Post.findByPk(postId);
if (!post || post.status !== 'published') {
throw new NotFoundError('文章不存在或未发布');
}

// 验证父评论是否存在
if (parentId) {
const parentComment = await Comment.findByPk(parentId);
if (!parentComment || parentComment.postId !== postId) {
throw new ValidationError('父评论不存在');
}
}

const comment = await Comment.create({
content,
postId,
parentId,
authorId,
status: 'approved' // 可以根据需要设置为 pending
});

// 更新文章评论数
await post.increment('commentCount');

return await this.getCommentById(comment.id);
}

static async getCommentById(commentId) {
const comment = await Comment.findByPk(commentId, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'nickname', 'avatar']
}
]
});

if (!comment) {
throw new NotFoundError('评论不存在');
}

return comment;
}

static async getPostComments(postId, options = {}) {
const { page = 1, limit = 20 } = options;
const offset = (page - 1) * limit;

// 获取顶级评论
const { count, rows } = await Comment.findAndCountAll({
where: {
postId,
parentId: null,
status: 'approved'
},
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'nickname', 'avatar']
},
{
model: Comment,
as: 'replies',
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'nickname', 'avatar']
}
],
where: { status: 'approved' },
required: false,
order: [['createdAt', 'ASC']]
}
],
order: [['createdAt', 'DESC']],
limit,
offset
});

return {
comments: rows,
pagination: {
total: count,
page,
limit,
pages: Math.ceil(count / limit)
}
};
}

static async updateComment(commentId, authorId, updateData) {
const comment = await Comment.findOne({
where: { id: commentId, authorId }
});

if (!comment) {
throw new NotFoundError('评论不存在或无权限');
}

await comment.update(updateData);
return await this.getCommentById(commentId);
}

static async deleteComment(commentId, authorId) {
const comment = await Comment.findOne({
where: { id: commentId, authorId }
});

if (!comment) {
throw new NotFoundError('评论不存在或无权限');
}

// 删除评论及其回复
await Comment.destroy({
where: {
[Op.or]: [
{ id: commentId },
{ parentId: commentId }
]
}
});

// 更新文章评论数
const post = await Post.findByPk(comment.postId);
if (post) {
const commentCount = await Comment.count({
where: { postId: comment.postId, status: 'approved' }
});
await post.update({ commentCount });
}

return true;
}
}

module.exports = CommentService;

API 路由实现

1. 认证中间件

// middleware/auth.js
const jwt = require('jsonwebtoken');
const { User } = require('../models');

const authenticateToken = async (req, res, next) => {
try {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];

if (!token) {
return res.status(401).json({
success: false,
message: '访问令牌缺失'
});
}

const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findByPk(decoded.userId);

if (!user || user.status !== 'active') {
return res.status(401).json({
success: false,
message: '无效的访问令牌'
});
}

req.user = user;
next();
} catch (error) {
return res.status(401).json({
success: false,
message: '无效的访问令牌'
});
}
};

const requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
message: '需要管理员权限'
});
}
next();
};

module.exports = {
authenticateToken,
requireAdmin
};

2. 用户路由

// routes/auth.js
const express = require('express');
const router = express.Router();
const UserService = require('../services/UserService');
const { authenticateToken } = require('../middleware/auth');
const { validateRequest } = require('../middleware/validation');
const { body } = require('express-validator');

// 注册
router.post('/register', [
body('username').isLength({ min: 3, max: 50 }).isAlphanumeric(),
body('email').isEmail(),
body('password').isLength({ min: 6 }),
validateRequest
], async (req, res) => {
try {
const result = await UserService.register(req.body);
res.status(201).json({
success: true,
data: result
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
});

// 登录
router.post('/login', [
body('email').isEmail(),
body('password').notEmpty(),
validateRequest
], async (req, res) => {
try {
const { email, password } = req.body;
const result = await UserService.login(email, password);
res.json({
success: true,
data: result
});
} catch (error) {
res.status(401).json({
success: false,
message: error.message
});
}
});

// 获取用户资料
router.get('/profile', authenticateToken, async (req, res) => {
try {
const user = await UserService.getUserProfile(req.user.id);
res.json({
success: true,
data: user
});
} catch (error) {
res.status(404).json({
success: false,
message: error.message
});
}
});

// 更新用户资料
router.put('/profile', authenticateToken, [
body('nickname').optional().isLength({ min: 1, max: 50 }),
body('bio').optional().isLength({ max: 500 }),
validateRequest
], async (req, res) => {
try {
const user = await UserService.updateProfile(req.user.id, req.body);
res.json({
success: true,
data: user
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
});

module.exports = router;

3. 文章路由

// routes/posts.js
const express = require('express');
const router = express.Router();
const PostService = require('../services/PostService');
const { authenticateToken } = require('../middleware/auth');
const { validateRequest } = require('../middleware/validation');
const { body, query } = require('express-validator');

// 获取文章列表
router.get('/', [
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 50 }),
query('categoryId').optional().isInt(),
query('tagId').optional().isInt(),
query('search').optional().isLength({ max: 100 }),
validateRequest
], async (req, res) => {
try {
const result = await PostService.getPostsList(req.query);
res.json({
success: true,
data: result
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
});

// 获取单篇文章
router.get('/:id', async (req, res) => {
try {
const post = await PostService.getPostById(req.params.id);
res.json({
success: true,
data: post
});
} catch (error) {
res.status(404).json({
success: false,
message: error.message
});
}
});

// 创建文章
router.post('/', authenticateToken, [
body('title').isLength({ min: 5, max: 200 }),
body('content').notEmpty(),
body('categoryId').isInt(),
body('tags').optional().isArray(),
body('status').optional().isIn(['draft', 'published']),
validateRequest
], async (req, res) => {
try {
const post = await PostService.createPost(req.user.id, req.body);
res.status(201).json({
success: true,
data: post
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
});

// 更新文章
router.put('/:id', authenticateToken, [
body('title').optional().isLength({ min: 5, max: 200 }),
body('content').optional().notEmpty(),
body('categoryId').optional().isInt(),
body('tags').optional().isArray(),
body('status').optional().isIn(['draft', 'published', 'archived']),
validateRequest
], async (req, res) => {
try {
const post = await PostService.updatePost(req.params.id, req.user.id, req.body);
res.json({
success: true,
data: post
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
});

// 删除文章
router.delete('/:id', authenticateToken, async (req, res) => {
try {
await PostService.deletePost(req.params.id, req.user.id);
res.json({
success: true,
message: '文章删除成功'
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
});

module.exports = router;

总结

今天我们构建了一个完整的博客系统,涵盖了:

  • ✅ 完整的数据库设计和模型关联
  • ✅ 用户认证和权限管理
  • ✅ 文章发布和管理系统
  • ✅ 评论系统实现
  • ✅ RESTful API 设计
  • ✅ 服务层架构设计

通过这个实战项目,你可以学到:

  • 如何设计复杂的数据库关联关系
  • 如何构建可维护的服务层架构
  • 如何实现完整的用户认证系统
  • 如何处理复杂的业务逻辑
  • 如何设计 RESTful API

这个博客系统包含了大部分 Web 应用的核心功能,是学习 Sequelize 和 Node.js 开发的绝佳实践项目!


相关文章推荐:

有问题欢迎留言讨论,我会及时回复大家!