从零到一:打造现代化全栈个人博客系统
经过一个月的全栈开发,我的个人博客系统 v1.0 正式完成。该系统基于 Node.js + Express + MySQL + Vue 3 + TypeScript 开发
📖 目录
- 项目概述
- 技术选型
- 系统架构
- 数据库设计
- 后端实现
- 前端实现
- 核心功能实现
- 技术亮点
- 性能优化
- 安全措施
- 部署指南
- 总结与展望
🎯 项目概述
为什么要做这个博客
- 想要「可控、可扩展、可维护」的内容平台,而非受限于现成平台
- 以真实项目串联学习 Vue 3 + TypeScript、Node.js + Express、MySQL 全栈能力
核心特性
✅ 前后端完全分离:RESTful API 设计,前后端独立开发部署
✅ 企业级架构:Sequelize ORM + 数据库连接池 + 统一错误处理
✅ 完整的内容管理:文章、分类、标签、评论、媒体文件一应俱全
✅ 用户认证系统:JWT Token + 角色权限控制(RBAC)
✅ 富媒体支持:图片上传、压缩、裁剪、预览
✅ 现代化前端:Vue 3 Composition API + TypeScript + Pinia 状态管理
✅ 响应式设计:支持桌面端、平板、移动端
✅ Markdown 编辑器:支持实时预览、语法高亮
✅ 评论系统:支持匿名评论、嵌套回复、审核机制
✅ 数据安全:密码加密、SQL 注入防护、XSS 防护
🛠 技术选型
后端技术栈
运行环境: Node.js 18+
Web 框架: Express 4.x
ORM 框架: Sequelize 6.x
数据库: MySQL 8.0+
认证方案: JWT (jsonwebtoken)
密码加密: bcryptjs
文件上传: Multer 1.4.x
图片处理: Sharp 0.33.x
数据验证: express-validator
前端技术栈
框架: Vue 3.3+
开发语言: TypeScript 5.x
构建工具: Vite 5.x
状态管理: Pinia 2.x
UI 组件库: Element Plus 2.x
路由管理: Vue Router 4.x
HTTP 客户端: Axios
Markdown: marked + highlight.js
工具库: dayjs, dompurify
为什么选择这些技术?
1. Node.js + Express
- 轻量灵活,易于上手
- 丰富的中间件生态
- 非阻塞 I/O,性能优异
- JavaScript 全栈开发,降低学习成本
2. Sequelize + MySQL
- ORM 抽象层,避免手写 SQL
- 自动处理 SQL 注入防护
- 支持事务、关联查询
- MySQL 成熟稳定,社区活跃
3. Vue 3 + TypeScript
- Composition API 代码组织更清晰
- TypeScript 类型检查提升开发体验
- Vite 构建速度快,开发体验好
- Element Plus 组件丰富,开箱即用
🏗 系统架构
整体架构图
┌─────────────────────────────────────────────┐
│ 前端层 (Vue 3) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 博客前台 │ │ 后台管理 │ │ 用户中心 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────┘↓ HTTP/REST API
┌─────────────────────────────────────────────┐
│ 后端层 (Node.js + Express) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 路由层 │ │ 业务逻辑 │ │ 数据访问 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────┘↓ Sequelize ORM
┌─────────────────────────────────────────────┐
│ 数据层 (MySQL) │
│ articles | categories | tags | comments │
│ users | media (媒体文件) │
└─────────────────────────────────────────────┘↓ 文件存储
┌─────────────────────────────────────────────┐
│ 存储层 (本地/对象存储) │
│ 本地磁盘 / 阿里云OSS / 腾讯云COS │
└─────────────────────────────────────────────┘
项目目录结构
后端目录结构
backend/
├── src/
│ ├── config/ # 配置文件
│ │ ├── database.js # 数据库配置
│ │ └── app.js # 应用配置
│ ├── models/ # Sequelize 模型
│ │ ├── index.js # 模型关联
│ │ ├── User.js # 用户模型
│ │ ├── Article.js # 文章模型
│ │ ├── Category.js # 分类模型
│ │ ├── Tag.js # 标签模型
│ │ ├── Comment.js # 评论模型
│ │ └── Media.js # 媒体模型
│ ├── controllers/ # 业务逻辑控制器
│ │ ├── articleController.js
│ │ ├── categoryController.js
│ │ ├── tagController.js
│ │ ├── commentController.js
│ │ ├── authController.js
│ │ └── mediaController.js
│ ├── routes/ # 路由定义
│ │ ├── index.js
│ │ ├── articles.js
│ │ ├── categories.js
│ │ ├── tags.js
│ │ ├── comments.js
│ │ ├── auth.js
│ │ └── media.js
│ ├── middlewares/ # 中间件
│ │ ├── auth.js # JWT 认证
│ │ ├── validator.js # 数据验证
│ │ ├── upload.js # 文件上传
│ │ └── errorHandler.js # 错误处理
│ └── utils/ # 工具函数
│ ├── response.js # 统一响应格式
│ ├── pagination.js # 分页工具
│ └── fileHelper.js # 文件处理
├── uploads/ # 文件上传目录
├── server.js # 服务器入口
├── package.json
└── .env # 环境变量
前端目录结构
frontend/
├── src/
│ ├── assets/ # 静态资源
│ ├── components/ # 公共组件
│ │ ├── article/ # 文章组件
│ │ ├── category/ # 分类组件
│ │ ├── comment/ # 评论组件
│ │ └── common/ # 通用组件
│ ├── views/ # 页面视图
│ │ ├── Home.vue # 首页
│ │ ├── ArticleDetail.vue
│ │ └── admin/ # 后台管理
│ │ ├── Dashboard.vue
│ │ ├── ArticleList.vue
│ │ └── ArticleEdit.vue
│ ├── stores/ # Pinia 状态管理
│ │ ├── user.ts
│ │ ├── article.ts
│ │ └── index.ts
│ ├── router/ # 路由配置
│ │ ├── index.ts
│ │ ├── frontend.ts
│ │ └── admin.ts
│ ├── api/ # API 接口
│ │ ├── request.ts # Axios 封装
│ │ ├── article.ts
│ │ ├── category.ts
│ │ └── comment.ts
│ ├── types/ # TypeScript 类型
│ │ └── index.ts
│ ├── App.vue
│ └── main.ts
├── index.html
├── vite.config.ts
└── package.json
🗄 数据库设计
ER 图关系
User (用户)↓ 1:N ↓ 1:N
Article (文章) ←──── N:N ────→ Tag (标签) Media (媒体)↓ N:1 (ArticleTag 中间表)
Category (分类)↓ 1:N
Comment (评论)
核心数据表设计
1. 用户表 (users)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT PK | 用户ID |
| username | VARCHAR(50) | 用户名 |
| VARCHAR(100) | 邮箱 | |
| password | VARCHAR(255) | 密码(bcrypt加密) |
| avatar | VARCHAR(255) | 头像URL |
| role | ENUM(‘admin’,‘user’) | 角色 |
| created_at | DATETIME | 创建时间 |
2. 文章表 (articles)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT PK | 文章ID |
| title | VARCHAR(200) | 标题 |
| summary | TEXT | 摘要 |
| content | LONGTEXT | 内容(Markdown) |
| cover_image | VARCHAR(255) | 封面图 |
| author_id | INT FK | 作者ID |
| category_id | INT FK | 分类ID |
| status | ENUM(‘draft’,‘published’) | 状态 |
| views | INT | 浏览次数 |
| is_top | BOOLEAN | 是否置顶 |
| published_at | DATETIME | 发布时间 |
3. 分类表 (categories)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT PK | 分类ID |
| name | VARCHAR(50) | 分类名称 |
| description | TEXT | 描述 |
| sort_order | INT | 排序 |
4. 标签表 (tags)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT PK | 标签ID |
| name | VARCHAR(50) | 标签名称 |
| color | VARCHAR(20) | 标签颜色 |
5. 评论表 (comments)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT PK | 评论ID |
| article_id | INT FK | 文章ID |
| user_id | INT FK | 用户ID |
| parent_id | INT FK | 父评论ID |
| nickname | VARCHAR(50) | 昵称 |
| VARCHAR(100) | 邮箱 | |
| content | TEXT | 评论内容 |
| is_approved | BOOLEAN | 是否审核 |
6. 媒体文件表 (media)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT PK | 媒体ID |
| filename | VARCHAR(255) | 原始文件名 |
| stored_name | VARCHAR(255) | 存储文件名(UUID) |
| file_url | VARCHAR(500) | 访问URL |
| file_size | INT | 文件大小(字节) |
| mime_type | VARCHAR(100) | MIME类型 |
| width | INT | 图片宽度 |
| height | INT | 图片高度 |
| uploader_id | INT FK | 上传者ID |
Sequelize 模型关联
// models/index.js
const { sequelize } = require('../config/database');// 导入所有模型
const User = require('./User')(sequelize);
const Article = require('./Article')(sequelize);
const Category = require('./Category')(sequelize);
const Tag = require('./Tag')(sequelize);
const Comment = require('./Comment')(sequelize);
const Media = require('./Media')(sequelize);// 定义关联关系
// 用户 -> 文章 (1:N)
User.hasMany(Article, { foreignKey: 'author_id', as: 'articles' });
Article.belongsTo(User, { foreignKey: 'author_id', as: 'author' });// 分类 -> 文章 (1:N)
Category.hasMany(Article, { foreignKey: 'category_id', as: 'articles' });
Article.belongsTo(Category, { foreignKey: 'category_id', as: 'category' });// 文章 <-> 标签 (N:N)
Article.belongsToMany(Tag, { through: 'article_tags', as: 'tags',foreignKey: 'article_id'
});
Tag.belongsToMany(Article, { through: 'article_tags', as: 'articles',foreignKey: 'tag_id'
});// 文章 -> 评论 (1:N)
Article.hasMany(Comment, { foreignKey: 'article_id', as: 'comments' });
Comment.belongsTo(Article, { foreignKey: 'article_id', as: 'article' });// 用户 -> 媒体 (1:N)
User.hasMany(Media, { foreignKey: 'uploader_id', as: 'media' });
Media.belongsTo(User, { foreignKey: 'uploader_id', as: 'uploader' });module.exports = {sequelize,User,Article,Category,Tag,Comment,Media
};
🔧 后端实现
1. 数据库连接配置
// src/config/database.js
require('dotenv').config();
const { Sequelize } = require('sequelize');const sequelize = new Sequelize(process.env.DB_NAME,process.env.DB_USER,process.env.DB_PASSWORD,{host: process.env.DB_HOST,port: process.env.DB_PORT || 3306,dialect: 'mysql',// UTF8MB4 字符集,支持 emojidialectOptions: {charset: 'utf8mb4',collate: 'utf8mb4_unicode_ci'},// 连接池配置pool: {max: 10, // 最大连接数min: 0, // 最小连接数acquire: 30000, // 获取连接超时idle: 10000 // 空闲超时},timezone: '+08:00',logging: process.env.NODE_ENV === 'development' ? console.log : false}
);module.exports = { sequelize };
2. JWT 认证中间件
// src/middlewares/auth.js
const jwt = require('jsonwebtoken');
const { sendError } = require('../utils/response');/*** JWT 认证中间件*/
const authenticate = (req, res, next) => {try {const authHeader = req.headers.authorization;if (!authHeader || !authHeader.startsWith('Bearer ')) {return sendError(res, '未提供认证令牌', 401);}const token = authHeader.substring(7);const decoded = jwt.verify(token, process.env.JWT_SECRET);// 将用户信息附加到请求对象req.user = decoded;next();} catch (error) {if (error.name === 'TokenExpiredError') {return sendError(res, '令牌已过期', 401);}if (error.name === 'JsonWebTokenError') {return sendError(res, '无效的令牌', 401);}return sendError(res, '认证失败', 401);}
};/*** 管理员权限验证*/
const isAdmin = (req, res, next) => {if (req.user.role !== 'admin') {return sendError(res, '需要管理员权限', 403);}next();
};module.exports = { authenticate, isAdmin };
3. 文章控制器实现
// src/controllers/articleController.js
const { Article, User, Category, Tag } = require('../models');
const { sendSuccess, sendError } = require('../utils/response');
const { paginate } = require('../utils/pagination');/*** 获取文章列表*/
exports.getArticles = async (req, res) => {try {const { page = 1, limit = 10, category_id, tag_id, keyword,status = 'published' } = req.query;// 构建查询条件const where = {};if (category_id) where.category_id = category_id;if (keyword) {where[Op.or] = [{ title: { [Op.like]: `%${keyword}%` } },{ summary: { [Op.like]: `%${keyword}%` } }];}// 前台仅显示已发布文章if (!req.user || req.user.role !== 'admin') {where.status = 'published';} else if (status) {where.status = status;}// 分页查询const { rows: articles, count: total } = await Article.findAndCountAll({where,include: [{ model: User, as: 'author', attributes: ['id', 'username', 'avatar'] },{ model: Category, as: 'category' },{ model: Tag, as: 'tags' }],order: [['is_top', 'DESC'],['published_at', 'DESC']],...paginate(page, limit)});sendSuccess(res, { articles, total, page, limit });} catch (error) {console.error('获取文章列表失败:', error);sendError(res, '获取文章列表失败', 500);}
};/*** 创建文章*/
exports.createArticle = async (req, res) => {try {const { title, summary, content, cover_image, category_id, tag_ids, status } = req.body;// 创建文章const article = await Article.create({title,summary,content,cover_image,category_id,author_id: req.user.id,status: status || 'draft',published_at: status === 'published' ? new Date() : null});// 关联标签if (tag_ids && tag_ids.length > 0) {await article.setTags(tag_ids);}// 返回完整的文章信息const newArticle = await Article.findByPk(article.id, {include: [{ model: User, as: 'author' },{ model: Category, as: 'category' },{ model: Tag, as: 'tags' }]});sendSuccess(res, newArticle, '文章创建成功', 201);} catch (error) {console.error('创建文章失败:', error);sendError(res, '创建文章失败', 500);}
};/*** 更新文章浏览量*/
exports.incrementViews = async (req, res) => {try {const { id } = req.params;await Article.increment('views', { where: { id } });sendSuccess(res, null, '浏览量更新成功');} catch (error) {console.error('更新浏览量失败:', error);sendError(res, '更新浏览量失败', 500);}
};
4. 文件上传中间件
// src/middlewares/upload.js
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');// 确保上传目录存在
const uploadDir = path.join(__dirname, '../../uploads/media');
if (!fs.existsSync(uploadDir)) {fs.mkdirSync(uploadDir, { recursive: true });
}// 配置存储
const storage = multer.diskStorage({destination: (req, file, cb) => {// 按年月创建子目录const date = new Date();const year = date.getFullYear();const month = String(date.getMonth() + 1).padStart(2, '0');const dir = path.join(uploadDir, `${year}/${month}`);if (!fs.existsSync(dir)) {fs.mkdirSync(dir, { recursive: true });}cb(null, dir);},filename: (req, file, cb) => {const ext = path.extname(file.originalname);const filename = `${Date.now()}-${uuidv4()}${ext}`;cb(null, filename);}
});// 文件过滤
const fileFilter = (req, file, cb) => {const allowedTypes = /jpeg|jpg|png|gif|webp/;const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());const mimetype = allowedTypes.test(file.mimetype);if (extname && mimetype) {cb(null, true);} else {cb(new Error('只支持图片格式: JPEG, PNG, GIF, WebP'));}
};const upload = multer({storage,fileFilter,limits: {fileSize: 5 * 1024 * 1024 // 5MB}
});module.exports = upload;
5. 图片处理工具
// src/utils/fileHelper.js
const sharp = require('sharp');/*** 压缩和优化图片*/
async function optimizeImage(inputPath, outputPath, options = {}) {const { width = 1920, quality = 85 } = options;const image = sharp(inputPath);const metadata = await image.metadata();// 如果图片宽度超过限制,进行缩放if (metadata.width > width) {await image.resize(width, null, { withoutEnlargement: true }).jpeg({ quality }).toFile(outputPath);} else {await image.jpeg({ quality }).toFile(outputPath);}return {width: metadata.width,height: metadata.height};
}/*** 获取图片尺寸*/
async function getImageSize(filePath) {const metadata = await sharp(filePath).metadata();return {width: metadata.width,height: metadata.height};
}module.exports = {optimizeImage,getImageSize
};
6. 统一响应格式
// src/utils/response.js/*** 成功响应*/
const sendSuccess = (res, data = null, message = 'success', statusCode = 200) => {res.status(statusCode).json({code: statusCode,message,data});
};/*** 错误响应*/
const sendError = (res, message = 'error', statusCode = 500, errors = null) => {res.status(statusCode).json({code: statusCode,message,...(errors && { errors })});
};module.exports = {sendSuccess,sendError
};
🎨 前端实现
1. Axios 请求封装
// src/api/request.ts
import axios, { AxiosError, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores'
import router from '@/router'// 创建 axios 实例
const request = axios.create({baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1',timeout: 10000
})// 请求拦截器:添加 token
request.interceptors.request.use((config) => {const userStore = useUserStore()if (userStore.token) {config.headers.Authorization = `Bearer ${userStore.token}`}return config},(error: AxiosError) => {console.error('请求错误:', error)return Promise.reject(error)}
)// 响应拦截器:统一错误处理
request.interceptors.response.use((response: AxiosResponse) => {return response.data},(error: AxiosError<any>) => {if (error.response) {const { status, data } = error.response// 401 未认证if (status === 401) {ElMessage.error('请先登录')const userStore = useUserStore()userStore.logout()router.push('/login')}// 403 无权限else if (status === 403) {ElMessage.error('没有权限访问')}// 其他错误else {ElMessage.error(data.message || '请求失败')}} else {ElMessage.error('网络错误,请检查网络连接')}return Promise.reject(error)}
)export default request
2. 文章 API 接口
// src/api/article.ts
import request from './request'
import type { Article, ArticleListParams, ArticleListResponse } from '@/types'/*** 获取文章列表*/
export const getArticles = (params: ArticleListParams) => {return request.get<ArticleListResponse>('/articles', { params })
}/*** 获取文章详情*/
export const getArticleById = (id: number) => {return request.get<Article>(`/articles/${id}`)
}/*** 创建文章*/
export const createArticle = (data: Partial<Article>) => {return request.post<Article>('/articles', data)
}/*** 更新文章*/
export const updateArticle = (id: number, data: Partial<Article>) => {return request.put<Article>(`/articles/${id}`, data)
}/*** 删除文章*/
export const deleteArticle = (id: number) => {return request.delete(`/articles/${id}`)
}/*** 增加浏览量*/
export const incrementArticleViews = (id: number) => {return request.post(`/articles/${id}/views`)
}
3. Pinia 状态管理
// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login as loginApi, register as registerApi, getProfile } from '@/api/auth'
import type { User, LoginParams, RegisterParams } from '@/types'export const useUserStore = defineStore('user', () => {// 状态const user = ref<User | null>(null)const token = ref<string>(localStorage.getItem('token') || '')// 计算属性const isLoggedIn = computed(() => !!token.value)const isAdmin = computed(() => user.value?.role === 'admin')// 登录const login = async (params: LoginParams) => {try {const res = await loginApi(params)token.value = res.data.tokenuser.value = res.data.userlocalStorage.setItem('token', res.data.token)return res} catch (error) {throw error}}// 注册const register = async (params: RegisterParams) => {try {const res = await registerApi(params)return res} catch (error) {throw error}}// 登出const logout = () => {user.value = nulltoken.value = ''localStorage.removeItem('token')}// 获取用户信息const fetchProfile = async () => {try {const res = await getProfile()user.value = res.datareturn res} catch (error) {logout()throw error}}return {user,token,isLoggedIn,isAdmin,login,register,logout,fetchProfile}
})
4. 路由配置
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores'const router = createRouter({history: createWebHistory(),routes: [{path: '/',name: 'Home',component: () => import('@/views/Home.vue'),meta: { title: '首页' }},{path: '/article/:id',name: 'ArticleDetail',component: () => import('@/views/ArticleDetail.vue'),meta: { title: '文章详情' }},{path: '/admin',component: () => import('@/layouts/AdminLayout.vue'),meta: { requiresAuth: true },children: [{path: '',name: 'Dashboard',component: () => import('@/views/admin/Dashboard.vue'),meta: { title: '控制台' }},{path: 'articles',name: 'ArticleList',component: () => import('@/views/admin/ArticleList.vue'),meta: { title: '文章管理' }},{path: 'articles/create',name: 'ArticleCreate',component: () => import('@/views/admin/ArticleEdit.vue'),meta: { title: '创建文章' }}]}]
})// 路由守卫
router.beforeEach((to, from, next) => {const userStore = useUserStore()// 设置页面标题document.title = to.meta.title ? `${to.meta.title} - 个人博客` : '个人博客'// 检查是否需要认证if (to.meta.requiresAuth && !userStore.isLoggedIn) {next({path: '/login',query: { redirect: to.fullPath }})} else {next()}
})export default router
5. TypeScript 类型定义
// src/types/index.ts// 用户类型
export interface User {id: numberusername: stringemail: stringavatar: stringrole: 'admin' | 'user'created_at: string
}// 文章类型
export interface Article {id: numbertitle: stringsummary: stringcontent: stringcover_image: stringauthor: Usercategory: Categorytags: Tag[]status: 'draft' | 'published'views: numberis_top: booleanpublished_at: stringcreated_at: stringupdated_at: string
}// 分类类型
export interface Category {id: numbername: stringdescription: stringarticle_count?: number
}// 标签类型
export interface Tag {id: numbername: stringcolor: stringarticle_count?: number
}// 评论类型
export interface Comment {id: numberarticle_id: numberuser_id: numberparent_id: number | nullnickname: stringemail: stringcontent: stringis_approved: booleancreated_at: stringchildren?: Comment[]
}// API 响应类型
export interface ApiResponse<T = any> {code: numbermessage: stringdata: T
}// 分页响应类型
export interface PaginationResponse<T> {total: numberpage: numberlimit: numberitems: T[]
}
💡 核心功能实现
1. JWT 认证流程
// 用户登录流程
exports.login = async (req, res) => {try {const { email, password } = req.body;// 1. 查找用户const user = await User.findOne({ where: { email } });if (!user) {return sendError(res, '用户不存在', 404);}// 2. 验证密码const isPasswordValid = await bcrypt.compare(password, user.password);if (!isPasswordValid) {return sendError(res, '密码错误', 401);}// 3. 生成 JWT Tokenconst token = jwt.sign({ id: user.id, username: user.username,role: user.role },process.env.JWT_SECRET,{ expiresIn: '7d' });// 4. 返回用户信息和 tokensendSuccess(res, {user: {id: user.id,username: user.username,email: user.email,avatar: user.avatar,role: user.role},token}, '登录成功');} catch (error) {sendError(res, '登录失败', 500);}
};
2. 文件上传流程
// 媒体文件上传
exports.uploadMedia = async (req, res) => {try {if (!req.file) {return sendError(res, '未上传文件', 400);}const file = req.file;// 1. 获取图片尺寸const { width, height } = await getImageSize(file.path);// 2. 压缩图片(可选)if (width > 1920) {await optimizeImage(file.path, file.path, { width: 1920, quality: 85 });}// 3. 生成访问 URLconst fileUrl = `${req.protocol}://${req.get('host')}/uploads/media/${file.filename}`;// 4. 保存到数据库const media = await Media.create({filename: file.originalname,stored_name: file.filename,file_path: file.path,file_url: fileUrl,file_size: file.size,mime_type: file.mimetype,width,height,uploader_id: req.user.id});sendSuccess(res, media, '上传成功', 201);} catch (error) {console.error('上传失败:', error);sendError(res, '上传失败', 500);}
};
3. 评论系统实现
// 发表评论
exports.createComment = async (req, res) => {try {const { articleId } = req.params;const { content, parent_id, nickname, email } = req.body;// 验证文章是否存在const article = await Article.findByPk(articleId);if (!article) {return sendError(res, '文章不存在', 404);}// 创建评论const comment = await Comment.create({article_id: articleId,user_id: req.user?.id || null,parent_id: parent_id || null,nickname: req.user ? req.user.username : nickname,email: req.user ? req.user.email : email,content,// 登录用户自动审核,匿名评论需审核is_approved: !!req.user});sendSuccess(res, comment, '评论成功', 201);} catch (error) {console.error('评论失败:', error);sendError(res, '评论失败', 500);}
};// 获取评论列表(带嵌套结构)
exports.getComments = async (req, res) => {try {const { articleId } = req.params;// 获取所有顶级评论const comments = await Comment.findAll({where: { article_id: articleId,parent_id: null,is_approved: true},include: [{ model: User, as: 'user', attributes: ['id', 'username', 'avatar'] }],order: [['created_at', 'DESC']]});// 递归获取子评论const commentsWithReplies = await Promise.all(comments.map(async (comment) => {const replies = await getCommentReplies(comment.id);return {...comment.toJSON(),replies};}));sendSuccess(res, commentsWithReplies);} catch (error) {sendError(res, '获取评论失败', 500);}
};
4. Markdown 编辑器组件
<!-- src/components/MarkdownEditor.vue -->
<template><div class="markdown-editor"><div class="toolbar"><el-button-group><el-button @click="insertText('**', '**')">加粗</el-button><el-button @click="insertText('*', '*')">斜体</el-button><el-button @click="insertText('`', '`')">代码</el-button><el-button @click="insertText('\n```\n', '\n```\n')">代码块</el-button><el-button @click="insertText('[', '](url)')">链接</el-button><el-button @click="handleImageUpload">图片</el-button></el-button-group><el-button @click="togglePreview" :type="showPreview ? 'primary' : ''">预览</el-button></div><div class="editor-container"><el-inputv-show="!showPreview"v-model="content"type="textarea":rows="20"placeholder="开始编写 Markdown..."@input="handleInput"/><div v-show="showPreview" class="markdown-preview" v-html="renderedHtml" /></div></div>
</template><script setup lang="ts">
import { ref, computed } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js'const props = defineProps<{modelValue: string
}>()const emit = defineEmits<{(e: 'update:modelValue', value: string): void
}>()const content = computed({get: () => props.modelValue,set: (value) => emit('update:modelValue', value)
})const showPreview = ref(false)// 配置 marked
marked.setOptions({highlight: (code, lang) => {if (lang && hljs.getLanguage(lang)) {return hljs.highlight(code, { language: lang }).value}return hljs.highlightAuto(code).value}
})// 渲染 Markdown
const renderedHtml = computed(() => {const html = marked(content.value)return DOMPurify.sanitize(html)
})// 插入文本
const insertText = (before: string, after: string) => {// 实现光标位置插入逻辑content.value += before + after
}// 切换预览
const togglePreview = () => {showPreview.value = !showPreview.value
}
</script><style scoped lang="scss">
.markdown-editor {border: 1px solid #dcdfe6;border-radius: 4px;.toolbar {display: flex;justify-content: space-between;padding: 10px;border-bottom: 1px solid #dcdfe6;background: #f5f7fa;}.editor-container {min-height: 400px;}.markdown-preview {padding: 20px;min-height: 400px;:deep(pre) {background: #f6f8fa;padding: 16px;border-radius: 6px;overflow-x: auto;}:deep(code) {font-family: 'Courier New', monospace;}}
}
</style>
✨ 技术亮点
1. 前后端完全分离
- RESTful API 设计,前后端独立开发和部署
- 统一的 API 响应格式,便于前端统一处理
- JWT Token 认证,无状态设计
2. 数据库设计规范
- Sequelize ORM 避免手写 SQL,防止 SQL 注入
- 完善的模型关联关系,支持联表查询
- 合理的索引设计,提升查询性能
- UTF8MB4 字符集,支持 emoji 表情
3. 文件上传优化
- Multer 中间件处理文件上传
- Sharp 图片压缩和处理
- 按年月自动创建目录结构
- UUID 命名防止文件名冲突
4. 前端工程化
- TypeScript 类型检查,减少运行时错误
- Pinia 状态管理,替代 Vuex
- Vite 极速构建,开发体验好
- 组件化开发,代码复用率高
5. 安全措施完善
- bcrypt 密码加密,不存储明文密码
- JWT Token 认证,Token 7 天有效期
- XSS 防护:DOMPurify 过滤 HTML
- CORS 跨域配置,限制访问来源
- 数据验证:express-validator
⚡ 性能优化
后端优化
// 1. 数据库连接池
pool: {max: 10,min: 0,acquire: 30000,idle: 10000
}// 2. 查询优化 - 只选择需要的字段
Article.findAll({attributes: ['id', 'title', 'summary', 'cover_image', 'published_at'],// ...
})// 3. 分页查询 - 避免一次性加载大量数据
const { offset, limit } = paginate(page, limit);
Article.findAndCountAll({ offset, limit })// 4. 索引优化
indexes: [{ fields: ['author_id'] },{ fields: ['category_id'] },{ fields: ['status'] },{ fields: ['published_at'] }
]// 5. Gzip 压缩
const compression = require('compression');
app.use(compression());
前端优化
// 1. 路由懒加载
{path: '/article/:id',component: () => import('@/views/ArticleDetail.vue')
}// 2. 组件按需引入
import { ElButton, ElMessage } from 'element-plus'// 3. 图片懒加载
<el-image lazy :src="article.cover_image" />// 4. 防抖优化
import { debounce } from 'lodash-es'
const handleSearch = debounce((keyword) => {// 搜索逻辑
}, 500)// 5. 虚拟滚动(长列表)
<el-virtual-list :items="articles" :item-size="100" />
🔒 安全措施
1. 密码安全
const bcrypt = require('bcryptjs');// 注册时加密密码
const hashedPassword = await bcrypt.hash(password, 10);// 登录时验证密码
const isValid = await bcrypt.compare(inputPassword, user.password);
2. JWT Token 认证
// 生成 Token
const token = jwt.sign({ id: user.id, username: user.username, role: user.role },process.env.JWT_SECRET,{ expiresIn: '7d' }
);// 验证 Token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
3. XSS 防护
// 前端使用 DOMPurify 过滤 HTML
import DOMPurify from 'dompurify'const cleanHtml = DOMPurify.sanitize(dirtyHtml)
4. CORS 配置
app.use(cors({origin: process.env.NODE_ENV === 'production' ? process.env.FRONTEND_URL : '*',credentials: true
}));
5. 数据验证
const { body, validationResult } = require('express-validator');// 验证规则
const validateArticle = [body('title').notEmpty().withMessage('标题不能为空'),body('content').notEmpty().withMessage('内容不能为空'),body('category_id').isInt().withMessage('分类ID必须是整数')
];// 验证中间件
const validate = (req, res, next) => {const errors = validationResult(req);if (!errors.isEmpty()) {return sendError(res, '数据验证失败', 400, errors.array());}next();
};
🚀 部署指南
开发环境
# 1. 安装依赖
cd backend && npm install
cd frontend && npm install# 2. 配置环境变量
cp backend/.env.example backend/.env
# 编辑 .env 填写数据库配置# 3. 初始化数据库
cd backend
npm run init-db# 4. 启动后端
npm run dev# 5. 启动前端(新终端)
cd frontend
npm run dev
生产环境部署
后端部署(使用 PM2)
# 1. 安装 PM2
npm install -g pm2# 2. 生产环境启动
cd backend
NODE_ENV=production pm2 start server.js --name blog-backend# 3. 保存 PM2 配置
pm2 save
pm2 startup
前端部署(Nginx)
# 1. 构建生产版本
cd frontend
npm run build# 2. 配置 Nginx
server {listen 80;server_name yourdomain.com;root /var/www/blog/dist;index index.html;# SPA 路由重定向location / {try_files $uri $uri/ /index.html;}# API 代理location /api {proxy_pass http://localhost:3000;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;}# 静态文件缓存location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {expires 1y;add_header Cache-Control "public, immutable";}
}# 3. 重启 Nginx
sudo nginx -t
sudo systemctl reload nginx
Docker 部署(可选)
# backend/Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
# docker-compose.yml
version: '3.8'
services:mysql:image: mysql:8.0environment:MYSQL_ROOT_PASSWORD: passwordMYSQL_DATABASE: blogvolumes:- mysql_data:/var/lib/mysqlports:- "3306:3306"backend:build: ./backendports:- "3000:3000"depends_on:- mysqlenvironment:DB_HOST: mysqlDB_NAME: blogfrontend:build: ./frontendports:- "80:80"depends_on:- backendvolumes:mysql_data:
📊 项目成果
完整的 API 接口
- ✅ 用户认证: 注册、登录、个人信息管理
- ✅ 文章管理: CRUD、分页、搜索、排序
- ✅ 分类管理: CRUD、文章统计
- ✅ 标签管理: CRUD、文章关联
- ✅ 评论系统: 发表、审核、嵌套回复
- ✅ 媒体管理: 上传、列表、删除
共计 33+ API 接口
完整的前端页面
- ✅ 博客首页: 文章列表、分类导航、热门标签
- ✅ 文章详情: Markdown 渲染、评论区、相关文章
- ✅ 分类/标签页: 按分类/标签浏览文章
- ✅ 后台管理: 文章管理、评论审核、媒体库
共计 15+ 页面组件
🎓 总结与展望
项目收获
通过这个项目,我们实现了:
- ✅ 全栈开发能力提升:掌握了从数据库设计到前端界面的完整开发流程
- ✅ 工程化思维培养:学会了如何组织代码结构、设计 API、管理状态
- ✅ 最佳实践应用:JWT 认证、ORM 使用、TypeScript 类型系统
- ✅ 安全意识增强:密码加密、XSS 防护、SQL 注入防护
- ✅ 性能优化经验:数据库索引、分页查询、图片压缩
未来扩展方向
- 搜索功能: 集成 Elasticsearch 实现全文搜索
- 邮件通知: 评论提醒、订阅通知
- 社交分享: 一键分享到微信、微博等平台
- Markdown 增强: 支持流程图、数学公式
- 多语言支持: i18n 国际化
- 主题切换: 夜间模式、自定义主题
- CDN 加速: 图片、静态资源 CDN 部署
- 缓存优化: Redis 缓存热门文章
- 数据统计: 访问量分析、用户行为追踪
- SSR 优化: Nuxt 3 服务端渲染,SEO 优化
最后的话
这个博客系统不仅是一个实用的个人网站,更是一个学习全栈开发的完整案例。从需求分析、架构设计、编码实现到部署上线,每一个环节都遵循了工业界的最佳实践。
希望这个项目能给你带来启发,无论是用于搭建自己的博客,还是作为学习全栈开发的参考案例。
如果你觉得这个项目有帮助,欢迎 Star ⭐️
📚 参考资源
- Express 官方文档
- Sequelize 官方文档
- Vue 3 官方文档
- Element Plus 官方文档
- TypeScript 官方文档
- MySQL 8.0 文档
日期: 2025-11-02
版本: v1.0
项目地址
- 前端
- 后端
技术栈: Node.js + Express + MySQL + Vue 3 + TypeScript + Echart
如果这篇文章对你有帮助,欢迎点赞、收藏、分享! 👍
