Sequelize 实战项目 - 构建完整的博客系统
发布时间:2024-04-15
作者:一介布衣
标签:Sequelize, 实战项目, 博客系统, 全栈开发
前言
今天咱们来做一个完整的实战项目 - 用 Sequelize 构建一个功能完整的博客系统。说实话,通过实际项目来学习技术是最有效的方法,能让你把之前学到的知识点串联起来。
我记得刚开始学 Sequelize 的时候,看了很多教程,但总感觉缺少实战经验。后来自己动手做了几个项目,才真正理解了 ORM 在实际开发中的应用。
今天我就带大家从零开始,构建一个包含用户管理、文章发布、评论系统、标签分类等功能的完整博客系统。
项目需求分析
功能需求
用户模块:
- 用户注册、登录、登出
- 用户资料管理
- 密码修改
- 头像上传
文章模块:
- 文章发布、编辑、删除
- 文章分类管理
- 文章标签系统
- 文章状态管理(草稿、发布、归档)
- 文章搜索
评论模块:
- 文章评论
- 评论回复
- 评论管理
管理模块:
- 用户管理
- 文章管理
- 评论管理
- 数据统计
技术栈
- 后端:Node.js + Express + Sequelize
- 数据库:MySQL
- 认证:JWT
- 文件上传:Multer
- API文档:Swagger
- 测试:Jest
数据库设计
1. 用户表(Users)
javascript
// 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)
javascript
// 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)
javascript
// 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)
javascript
// 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)
javascript
// 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. 用户服务
javascript
// 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. 文章服务
javascript
// 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. 评论服务
javascript
// 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. 认证中间件
javascript
// 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. 用户路由
javascript
// 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. 文章路由
javascript
// 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 开发的绝佳实践项目!
相关文章推荐:
有问题欢迎留言讨论,我会及时回复大家!