实战项目: 个人博客系统¶
难度: ⭐⭐⭐⭐ 高级 时间: 20-25小时 涉及知识: Flask、数据库、用户认证、RESTful API、前端开发
📖 项目概述¶
项目背景¶
个人博客系统是一个经典的Web开发项目,涵盖了Web开发的核心技术栈。通过构建一个完整的博客系统,你将学习到: - 前后端分离架构 - 数据库设计和操作 - 用户认证和授权 - RESTful API设计 - 前端界面开发 - 部署和运维
项目目标¶
构建一个功能完整的个人博客系统,能够: - 用户注册和登录 - 发布和管理博客文章 - 文章分类和标签 - 评论系统 - 富文本编辑器 - 响应式设计 - SEO优化
技术栈¶
- 后端框架: Flask
- 数据库: SQLite / PostgreSQL
- ORM: SQLAlchemy
- 前端框架: Bootstrap 5 / Vue.js
- 富文本编辑器: TinyMCE / Quill
- 表单验证: WTForms
- 用户认证: Flask-Login
- API文档: Flask-RESTX / Swagger
🏗️ 项目结构¶
blog-system/
├── app/ # 应用主目录
│ ├── __init__.py # 应用工厂
│ ├── config.py # 配置文件
│ ├── models.py # 数据模型
│ ├── forms.py # 表单定义
│ ├── extensions.py # 扩展初始化
│ ├── auth/ # 认证模块
│ │ ├── __init__.py
│ │ ├── routes.py # 认证路由
│ │ └── forms.py # 认证表单
│ ├── main/ # 主模块
│ │ ├── __init__.py
│ │ ├── routes.py # 主页路由
│ │ └── forms.py # 主页表单
│ ├── post/ # 文章模块
│ │ ├── __init__.py
│ │ ├── routes.py # 文章路由
│ │ └── forms.py # 文章表单
│ ├── api/ # API模块
│ │ ├── __init__.py
│ │ ├── routes.py # API路由
│ │ └── schemas.py # API模式
│ ├── templates/ # 模板文件
│ │ ├── base.html # 基础模板
│ │ ├── index.html # 首页
│ │ ├── auth/ # 认证模板
│ │ ├── post/ # 文章模板
│ │ └── api/ # API文档
│ └── static/ # 静态文件
│ ├── css/ # 样式文件
│ ├── js/ # JavaScript文件
│ └── images/ # 图片文件
├── migrations/ # 数据库迁移
├── tests/ # 测试目录
│ ├── test_auth.py
│ ├── test_post.py
│ └── test_api.py
├── utils/ # 工具函数
│ ├── __init__.py
│ ├── decorators.py # 装饰器
│ └── helpers.py # 辅助函数
├── requirements.txt # Python依赖
├── Dockerfile # Docker配置
├── docker-compose.yml # Docker Compose配置
├── .env.example # 环境变量示例
└── README.md # 项目说明
🎯 核心功能¶
1. 用户管理¶
- 用户注册: 新用户注册
- 用户登录: 用户登录和登出
- 个人资料: 编辑个人资料
- 密码重置: 忘记密码重置
- 权限管理: 管理员权限
2. 文章管理¶
- 发布文章: 创建和发布文章
- 编辑文章: 编辑已有文章
- 删除文章: 删除文章
- 文章列表: 查看所有文章
- 文章详情: 查看文章详情
- 草稿箱: 保存草稿
3. 分类和标签¶
- 分类管理: 创建和管理分类
- 标签管理: 创建和管理标签
- 分类筛选: 按分类筛选文章
- 标签筛选: 按标签筛选文章
4. 评论系统¶
- 发表评论: 对文章发表评论
- 回复评论: 回复他人评论
- 删除评论: 删除不当评论
- 评论审核: 审核评论内容
5. 搜索功能¶
- 全文搜索: 搜索文章内容
- 高级搜索: 按分类、标签、日期搜索
- 搜索结果: 显示搜索结果
6. 响应式设计¶
- 移动端适配: 适配各种屏幕尺寸
- 主题切换: 支持明暗主题
- 自定义样式: 自定义CSS样式
💻 代码实现¶
1. 配置文件 (app/config.py)¶
"""
配置文件
"""
import os
from pathlib import Path
from datetime import timedelta
class Config:
"""基础配置"""
# 基础配置
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key'
DEBUG = False
TESTING = False
# 数据库配置
BASE_DIR = Path(__file__).resolve().parent.parent
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL',
f'sqlite:///{BASE_DIR}/blog.db'
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False
# 会话配置
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
SESSION_COOKIE_SECURE = False
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
# 文件上传配置
UPLOAD_FOLDER = BASE_DIR / 'app' / 'static' / 'uploads'
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
# 分页配置
POSTS_PER_PAGE = 10
COMMENTS_PER_PAGE = 20
# 邮件配置
MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.gmail.com')
MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@blog.com')
# 管理员配置
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', 'admin@blog.com')
# SEO配置
SITE_NAME = '我的博客'
SITE_DESCRIPTION = '一个基于Flask的个人博客系统'
SITE_KEYWORDS = '博客, Flask, Python, Web开发'
class DevelopmentConfig(Config):
"""开发环境配置"""
DEBUG = True
SQLALCHEMY_ECHO = True
class TestingConfig(Config):
"""测试环境配置"""
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
WTF_CSRF_ENABLED = False
class ProductionConfig(Config):
"""生产环境配置"""
SESSION_COOKIE_SECURE = True
# ⚠️ 生产环境还应配置安全头,建议使用 Flask-Talisman 库:
# HSTS、X-Frame-Options、Content-Security-Policy、X-Content-Type-Options 等
@classmethod # @classmethod类方法,第一个参数为类本身
def init_app(cls, app):
"""生产环境初始化"""
import logging
from logging.handlers import RotatingFileHandler
# 配置日志
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler(
'logs/blog.log',
maxBytes=10240000,
backupCount=10
)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('博客系统启动')
# 配置字典
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
2. 数据模型 (app/models.py)¶
"""
数据模型
"""
from datetime import datetime, timezone
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
from app import db, login_manager
class User(UserMixin, db.Model):
"""用户模型"""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(256), nullable=False)
avatar = db.Column(db.String(256), default='default_avatar.png')
bio = db.Column(db.Text)
is_admin = db.Column(db.Boolean, default=False)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
last_seen = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
# 关系
posts = db.relationship('Post', backref='author', lazy='dynamic', cascade='all, delete-orphan')
comments = db.relationship('Comment', backref='author', lazy='dynamic', cascade='all, delete-orphan')
def set_password(self, password):
"""设置密码"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""检查密码"""
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
@login_manager.user_loader
def load_user(user_id):
"""加载用户"""
return db.session.get(User, int(user_id))
class Category(db.Model):
"""分类模型"""
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True, nullable=False)
slug = db.Column(db.String(64), unique=True, nullable=False)
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
# 关系
posts = db.relationship('Post', backref='category', lazy='dynamic')
def __repr__(self):
return f'<Category {self.name}>'
class Tag(db.Model):
"""标签模型"""
__tablename__ = 'tags'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True, nullable=False)
slug = db.Column(db.String(64), unique=True, nullable=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
def __repr__(self):
return f'<Tag {self.name}>'
# 文章和标签的多对多关系
post_tags = db.Table('post_tags',
db.Column('post_id', db.Integer, db.ForeignKey('posts.id'), primary_key=True),
db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True)
)
class Post(db.Model):
"""文章模型"""
__tablename__ = 'posts'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(128), nullable=False)
slug = db.Column(db.String(128), unique=True, nullable=False, index=True)
content = db.Column(db.Text, nullable=False)
excerpt = db.Column(db.Text)
featured_image = db.Column(db.String(256))
is_published = db.Column(db.Boolean, default=False)
is_featured = db.Column(db.Boolean, default=False)
view_count = db.Column(db.Integer, default=0)
# lambda延迟求值:每次插入/更新记录时调用获取当前时间,若直接写datetime.now()则所有记录共享模块加载时的同一时间
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True)
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# 外键
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'))
# 关系
tags = db.relationship('Tag', secondary=post_tags, lazy='subquery',
backref=db.backref('posts', lazy=True))
comments = db.relationship('Comment', backref='post', lazy='dynamic', cascade='all, delete-orphan')
def __repr__(self):
return f'<Post {self.title}>'
class Comment(db.Model):
"""评论模型"""
__tablename__ = 'comments'
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
is_approved = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
# 外键
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False)
parent_id = db.Column(db.Integer, db.ForeignKey('comments.id'))
# 关系
replies = db.relationship('Comment', backref=db.backref('parent', remote_side=[id]), lazy='dynamic')
def __repr__(self):
return f'<Comment {self.id}>'
3. 表单定义 (app/forms.py)¶
"""
表单定义
"""
from flask_wtf import FlaskForm
from wtforms import (
StringField, TextAreaField, PasswordField, BooleanField,
SelectField, FileField, SubmitField
)
from wtforms.validators import (
DataRequired, Email, EqualTo, Length, ValidationError, Regexp
)
from app.models import User
class LoginForm(FlaskForm):
"""登录表单"""
username = StringField('用户名', validators=[DataRequired(), Length(1, 64)])
password = PasswordField('密码', validators=[DataRequired()])
remember_me = BooleanField('记住我')
submit = SubmitField('登录')
class RegistrationForm(FlaskForm):
"""注册表单"""
username = StringField(
'用户名',
validators=[
DataRequired(),
Length(1, 64),
Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,
'用户名只能包含字母、数字、下划线和点')
]
)
email = StringField('邮箱', validators=[DataRequired(), Email(), Length(1, 120)])
password = PasswordField('密码', validators=[DataRequired(), Length(6, 128)])
password2 = PasswordField(
'确认密码',
validators=[DataRequired(), EqualTo('password', message='两次输入的密码不一致')]
)
submit = SubmitField('注册')
def validate_username(self, field):
"""验证用户名"""
if User.query.filter_by(username=field.data).first():
raise ValidationError('用户名已被使用')
def validate_email(self, field):
"""验证邮箱"""
if User.query.filter_by(email=field.data).first():
raise ValidationError('邮箱已被注册')
class EditProfileForm(FlaskForm):
"""编辑资料表单"""
username = StringField('用户名', validators=[DataRequired(), Length(1, 64)])
email = StringField('邮箱', validators=[DataRequired(), Email(), Length(1, 120)])
bio = TextAreaField('个人简介', validators=[Length(0, 500)])
avatar = FileField('头像')
submit = SubmitField('保存')
class PostForm(FlaskForm):
"""文章表单"""
title = StringField('标题', validators=[DataRequired(), Length(1, 128)])
content = TextAreaField('内容', validators=[DataRequired()])
excerpt = TextAreaField('摘要', validators=[Length(0, 500)])
category_id = SelectField('分类', coerce=int, validators=[DataRequired()])
tags = StringField('标签', description='用逗号分隔多个标签')
featured_image = FileField('封面图片')
is_published = BooleanField('立即发布')
is_featured = BooleanField('设为精选')
submit = SubmitField('保存')
class CommentForm(FlaskForm):
"""评论表单"""
content = TextAreaField('评论内容', validators=[DataRequired(), Length(1, 1000)])
submit = SubmitField('发表评论')
class SearchForm(FlaskForm):
"""搜索表单"""
query = StringField('搜索', validators=[DataRequired()])
submit = SubmitField('搜索')
4. 认证路由 (app/auth/routes.py)¶
📌 SQLAlchemy 2.0 迁移提示:本项目路由代码使用
Model.query.*旧式写法(Flask-SQLAlchemy 3.x 仍兼容)。 新项目建议统一使用 SQLAlchemy 2.0 风格,例如: -User.query.filter_by(username=name).first()→db.session.execute(db.select(User).filter_by(username=name)).scalar_one_or_none()-Post.query.all()→db.session.execute(db.select(Post)).scalars().all()-Post.query.get_or_404(id)→db.get_or_404(Post, id)详见 05-数据库集成 中的对照示例。
"""
认证路由
"""
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, current_user
from datetime import datetime, timezone
from app import db
from app.auth import auth_bp
from app.forms import LoginForm, RegistrationForm, EditProfileForm
from app.models import User
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""用户登录"""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('用户名或密码错误', 'danger')
return redirect(url_for('auth.login'))
if not user.is_active:
flash('账号已被禁用', 'danger')
return redirect(url_for('auth.login'))
login_user(user, remember=form.remember_me.data)
user.last_seen = datetime.now(timezone.utc)
db.session.commit()
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('main.index')
flash(f'欢迎回来,{user.username}!', 'success')
return redirect(next_page)
return render_template('auth/login.html', title='登录', form=form)
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
"""用户注册"""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(
username=form.username.data,
email=form.email.data
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('注册成功!请登录。', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', title='注册', form=form)
@auth_bp.route('/logout')
def logout():
"""用户登出"""
logout_user()
flash('您已成功登出', 'info')
return redirect(url_for('main.index'))
@auth_bp.route('/profile', methods=['GET', 'POST'])
def profile():
"""用户资料"""
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
form = EditProfileForm(obj=current_user)
if form.validate_on_submit():
# 检查用户名是否被占用
if form.username.data != current_user.username:
user = User.query.filter_by(username=form.username.data).first()
if user:
flash('用户名已被使用', 'danger')
return redirect(url_for('auth.profile'))
# 更新用户信息
current_user.username = form.username.data
current_user.email = form.email.data
current_user.bio = form.bio.data
# 处理头像上传
if form.avatar.data:
# TODO: 实现头像上传逻辑
pass
db.session.commit()
flash('资料更新成功', 'success')
return redirect(url_for('auth.profile'))
return render_template('auth/profile.html', title='个人资料', form=form)
5. 文章路由 (app/post/routes.py)¶
"""
文章路由
"""
from flask import render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user
from datetime import datetime, timezone
from app import db
from app.post import post_bp
from app.forms import PostForm, CommentForm
from app.models import Post, Comment, Category, Tag
from app.utils.decorators import admin_required
@post_bp.route('/')
def index():
"""文章列表"""
page = request.args.get('page', 1, type=int)
posts = Post.query.filter_by(is_published=True)\
.order_by(Post.created_at.desc())\
.paginate(page=page, per_page=10, error_out=False)
return render_template('post/index.html', posts=posts)
@post_bp.route('/post/<slug>')
def detail(slug):
"""文章详情"""
post = Post.query.filter_by(slug=slug).first_or_404()
# 增加浏览次数
# ⚠️ 并发安全提示:post.view_count += 1 是先读后写,存在竞态条件(race condition)。
# 在高并发场景下应使用数据库原子操作:
# Django ORM: Post.objects.filter(id=post.id).update(view_count=F('view_count') + 1)
# 原生 SQL: UPDATE posts SET view_count = view_count + 1 WHERE id = :id
post.view_count += 1
db.session.commit()
# 获取评论
page = request.args.get('page', 1, type=int)
comments = Comment.query.filter_by(post_id=post.id, is_approved=True)\
.order_by(Comment.created_at.asc())\
.paginate(page=page, per_page=20, error_out=False)
form = CommentForm()
return render_template('post/detail.html', post=post, comments=comments, form=form)
@post_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create():
"""创建文章"""
form = PostForm()
# 设置分类选项
categories = Category.query.all()
form.category_id.choices = [(0, '选择分类')] + [(c.id, c.name) for c in categories]
if form.validate_on_submit():
# 创建文章
post = Post(
title=form.title.data,
content=form.content.data,
excerpt=form.excerpt.data,
category_id=form.category_id.data,
is_published=form.is_published.data,
is_featured=form.is_featured.data,
user_id=current_user.id
)
# 生成slug
post.slug = generate_slug(form.title.data)
# 处理标签
if form.tags.data:
tags = [tag.strip() for tag in form.tags.data.split(',')]
for tag_name in tags:
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag = Tag(name=tag_name, slug=generate_slug(tag_name))
db.session.add(tag)
post.tags.append(tag)
# 处理封面图片
if form.featured_image.data:
# TODO: 实现图片上传逻辑
pass
db.session.add(post)
db.session.commit()
flash('文章创建成功!', 'success')
return redirect(url_for('post.detail', slug=post.slug))
return render_template('post/create.html', title='创建文章', form=form)
@post_bp.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit(id):
"""编辑文章"""
post = db.get_or_404(Post, id)
# 检查权限
if post.author != current_user and not current_user.is_admin:
abort(403)
form = PostForm(obj=post)
# 设置分类选项
categories = Category.query.all()
form.category_id.choices = [(0, '选择分类')] + [(c.id, c.name) for c in categories]
# 设置标签
if post.tags:
form.tags.data = ', '.join([tag.name for tag in post.tags])
if form.validate_on_submit():
# 更新文章
post.title = form.title.data
post.content = form.content.data
post.excerpt = form.excerpt.data
post.category_id = form.category_id.data
post.is_published = form.is_published.data
post.is_featured = form.is_featured.data
post.updated_at = datetime.now(timezone.utc)
# 处理标签
post.tags.clear()
if form.tags.data:
tags = [tag.strip() for tag in form.tags.data.split(',')]
for tag_name in tags:
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag = Tag(name=tag_name, slug=generate_slug(tag_name))
db.session.add(tag)
post.tags.append(tag)
db.session.commit()
flash('文章更新成功!', 'success')
return redirect(url_for('post.detail', slug=post.slug))
return render_template('post/edit.html', title='编辑文章', form=form, post=post)
@post_bp.route('/delete/<int:id>', methods=['POST'])
@login_required
def delete(id):
"""删除文章"""
post = db.get_or_404(Post, id)
# 检查权限
if post.author != current_user and not current_user.is_admin:
abort(403)
db.session.delete(post)
db.session.commit()
flash('文章已删除', 'success')
return redirect(url_for('post.index'))
@post_bp.route('/post/<int:post_id>/comment', methods=['POST'])
@login_required
def add_comment(post_id):
"""添加评论"""
post = db.get_or_404(Post, post_id)
form = CommentForm()
if form.validate_on_submit():
comment = Comment(
content=form.content.data,
user_id=current_user.id,
post_id=post_id
)
# 处理回复
parent_id = request.form.get('parent_id')
if parent_id:
comment.parent_id = int(parent_id)
db.session.add(comment)
db.session.commit()
flash('评论已提交,等待审核', 'success')
return redirect(url_for('post.detail', slug=post.slug))
def generate_slug(text):
"""生成slug"""
import re
from unidecode import unidecode
# 转换为ASCII
text = unidecode(text).lower()
# 移除特殊字符
text = re.sub(r'[^\w\s-]', '', text)
text = re.sub(r'[-\s]+', '-', text)
return text.strip('-')
6. API路由 (app/api/routes.py)¶
"""
API路由
"""
from flask import jsonify, request
from flask_restx import Namespace, Resource, fields
from app import db
from app.models import Post, User, Category, Tag, Comment
from app.api import api_ns
# 定义API模型
post_model = api_ns.model('Post', {
'id': fields.Integer,
'title': fields.String,
'slug': fields.String,
'content': fields.String,
'excerpt': fields.String,
'created_at': fields.DateTime,
'updated_at': fields.DateTime,
'view_count': fields.Integer,
'author': fields.String(attribute='author.username'),
'category': fields.String(attribute=lambda x: x.category.name if x.category else None)
})
@api_ns.route('/posts')
class PostListAPI(Resource):
"""文章列表API"""
@api_ns.marshal_with(post_model, as_list=True)
def get(self):
"""获取文章列表"""
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
posts = Post.query.filter_by(is_published=True)\
.order_by(Post.created_at.desc())\
.paginate(page=page, per_page=per_page, error_out=False)
return posts.items
@api_ns.route('/posts/<int:id>')
class PostDetailAPI(Resource):
"""文章详情API"""
@api_ns.marshal_with(post_model)
def get(self, id):
"""获取文章详情"""
post = db.get_or_404(Post, id)
return post
@api_ns.route('/categories')
class CategoryListAPI(Resource):
"""分类列表API"""
def get(self):
"""获取分类列表"""
categories = Category.query.all()
return [{
'id': c.id,
'name': c.name,
'slug': c.slug,
'post_count': c.posts.count()
} for c in categories]
@api_ns.route('/tags')
class TagListAPI(Resource):
"""标签列表API"""
def get(self):
"""获取标签列表"""
tags = Tag.query.all()
return [{
'id': t.id,
'name': t.name,
'slug': t.slug,
'post_count': len(t.posts)
} for t in tags]
7. 基础模板 (app/templates/base.html)¶
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ config.SITE_DESCRIPTION }}">
<meta name="keywords" content="{{ config.SITE_KEYWORDS }}">
<title>{% block title %}{{ config.SITE_NAME }}{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Custom CSS -->
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="{{ url_for('main.index') }}">
{{ config.SITE_NAME }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.index') }}">首页</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('post.index') }}">文章</a>
</li>
</ul>
<form class="d-flex" action="{{ url_for('main.search') }}" method="GET">
<input class="form-control me-2" type="search" name="q" placeholder="搜索...">
<button class="btn btn-outline-light" type="submit">搜索</button>
</form>
<ul class="navbar-nav ms-3">
{% if current_user.is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
{{ current_user.username }}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">个人资料</a></li>
<li><a class="dropdown-item" href="{{ url_for('post.create') }}">写文章</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">退出</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">登录</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.register') }}">注册</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- Flash消息 -->
<div class="container mt-3">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<!-- 主内容 -->
<main class="container mt-4">
{% block content %}{% endblock %}
</main>
<!-- 页脚 -->
<footer class="bg-light mt-5 py-4">
<div class="container text-center">
<p class="mb-0">© {{ now().year }} {{ config.SITE_NAME }}. All rights reserved.</p>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
8. 文章详情模板 (app/templates/post/detail.html)¶
{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.SITE_NAME }}{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-8">
<!-- 文章内容 -->
<article class="blog-post">
<header class="mb-4">
<h1 class="blog-post-title">{{ post.title }}</h1>
<div class="blog-post-meta text-muted">
<span>作者: {{ post.author.username }}</span>
<span class="mx-2">|</span>
<span>发布于: {{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
<span class="mx-2">|</span>
<span>分类: {{ post.category.name if post.category else '未分类' }}</span>
<span class="mx-2">|</span>
<span>阅读: {{ post.view_count }}</span>
</div>
{% if post.tags %}
<div class="mt-2">
{% for tag in post.tags %}
<a href="{{ url_for('main.tag', slug=tag.slug) }}" class="badge bg-secondary text-decoration-none me-1">
{{ tag.name }}
</a>
{% endfor %}
</div>
{% endif %}
</header>
{% if post.featured_image %}
<img src="{{ post.featured_image }}" alt="{{ post.title }}" class="img-fluid mb-4">
{% endif %}
<div class="blog-post-content">
{# ⚠️ 生产环境必须对内容进行HTML消毒(例如使用bleach库)再使用|safe #}
{# 示例: post.content = bleach.clean(raw_content, tags=ALLOWED_TAGS) #}
{{ post.content|safe }}
</div>
</article>
<!-- 评论区 -->
<section class="mt-5">
<h3>评论 ({{ comments.total }})</h3>
{% if current_user.is_authenticated %}
<form action="{{ url_for('post.add_comment', post_id=post.id) }}" method="POST" class="mb-4">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.content.label(class="form-label") }}
{{ form.content(class="form-control", rows="4") }}
</div>
<button type="submit" class="btn btn-primary">发表评论</button>
</form>
{% else %}
<div class="alert alert-info">
<a href="{{ url_for('auth.login') }}">登录</a> 后发表评论
</div>
{% endif %}
{% for comment in comments.items %}
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between">
<h6 class="card-title">{{ comment.author.username }}</h6>
<small class="text-muted">{{ comment.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
</div>
<p class="card-text mt-2">{{ comment.content }}</p>
{% if current_user.is_authenticated %}
<a href="#" class="btn btn-sm btn-link">回复</a>
{% endif %}
</div>
</div>
{% endfor %}
<!-- 分页 -->
{% if comments.pages > 1 %}
<nav aria-label="评论分页">
<ul class="pagination">
{% if comments.has_prev %}
<li class="page-item">
<a class="page-link" href="?page={{ comments.prev_num }}">上一页</a>
</li>
{% endif %}
{% for page_num in comments.iter_pages() %}
{% if page_num %}
<li class="page-item {{ 'active' if page_num == comments.page else '' }}">
<a class="page-link" href="?page={{ page_num }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endfor %}
{% if comments.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ comments.next_num }}">下一页</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</section>
</div>
<!-- 侧边栏 -->
<div class="col-lg-4">
<!-- 分类 -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">分类</h5>
</div>
<ul class="list-group list-group-flush">
{% for category in categories %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<a href="{{ url_for('main.category', slug=category.slug) }}" class="text-decoration-none">
{{ category.name }}
</a>
<span class="badge bg-primary rounded-pill">{{ category.posts.count() }}</span>
</li>
{% endfor %}
</ul>
</div>
<!-- 标签 -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">标签</h5>
</div>
<div class="card-body">
{% for tag in tags %}
<a href="{{ url_for('main.tag', slug=tag.slug) }}" class="badge bg-secondary text-decoration-none me-1 mb-1">
{{ tag.name }}
</a>
{% endfor %}
</div>
</div>
<!-- 热门文章 -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">热门文章</h5>
</div>
<ul class="list-group list-group-flush">
{% for post in popular_posts %}
<li class="list-group-item">
<a href="{{ url_for('post.detail', slug=post.slug) }}" class="text-decoration-none">
{{ post.title }}
</a>
<small class="text-muted">{{ post.view_count }} 阅读</small>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-python.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-javascript.min.js"></script>
{% endblock %}
9. 应用工厂 (app/init.py)¶
"""
应用工厂
"""
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_wtf.csrf import CSRFProtect
from app.config import config
# 初始化扩展
db = SQLAlchemy()
login_manager = LoginManager()
migrate = Migrate()
csrf = CSRFProtect()
def create_app(config_name='default'):
"""创建应用实例"""
app = Flask(__name__)
# 加载配置
app.config.from_object(config[config_name])
# 初始化扩展
db.init_app(app)
login_manager.init_app(app)
migrate.init_app(app, db)
csrf.init_app(app)
# 注册蓝图
from app.main import main_bp
from app.auth import auth_bp
from app.post import post_bp
from app.api import api_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(post_bp, url_prefix='/post')
app.register_blueprint(api_bp, url_prefix='/api')
# 注册错误处理器
register_error_handlers(app)
# 注册上下文处理器
register_context_processors(app)
return app
def register_error_handlers(app):
"""注册错误处理器"""
from flask import render_template
@app.errorhandler(404)
def not_found_error(error):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
db.session.rollback()
return render_template('errors/500.html'), 500
def register_context_processors(app):
"""注册上下文处理器"""
from datetime import datetime, timezone
@app.context_processor
def utility_processor():
return {
'now': lambda: datetime.now(timezone.utc) # lambda匿名函数:简洁的单行函数
}
10. 主路由 (app/main/routes.py)¶
"""
主路由
"""
from flask import render_template, redirect, url_for, request
from app import db
from app.models import Post, Category, Tag
from app.main import main_bp
@main_bp.route('/')
def index():
"""首页"""
# 获取精选文章
featured_posts = Post.query.filter_by(
is_published=True,
is_featured=True
).order_by(Post.created_at.desc()).limit(5).all()
# 获取最新文章
latest_posts = Post.query.filter_by(is_published=True)\
.order_by(Post.created_at.desc())\
.limit(10).all()
# 获取分类
categories = Category.query.all()
# 获取标签
tags = Tag.query.all()
return render_template('index.html',
featured_posts=featured_posts,
latest_posts=latest_posts,
categories=categories,
tags=tags)
@main_bp.route('/search')
def search():
"""搜索"""
query = request.args.get('q', '')
page = request.args.get('page', 1, type=int)
if query:
posts = Post.query.filter(
Post.is_published == True,
Post.title.contains(query) | Post.content.contains(query)
).order_by(Post.created_at.desc())\
.paginate(page=page, per_page=10, error_out=False)
else:
posts = None
return render_template('search.html', query=query, posts=posts)
@main_bp.route('/category/<slug>')
def category(slug):
"""分类页面"""
category = Category.query.filter_by(slug=slug).first_or_404()
page = request.args.get('page', 1, type=int)
posts = Post.query.filter_by(
is_published=True,
category_id=category.id
).order_by(Post.created_at.desc())\
.paginate(page=page, per_page=10, error_out=False)
return render_template('category.html', category=category, posts=posts)
@main_bp.route('/tag/<slug>')
def tag(slug):
"""标签页面"""
tag = Tag.query.filter_by(slug=slug).first_or_404()
page = request.args.get('page', 1, type=int)
posts = tag.posts.filter(Post.is_published == True)\
.order_by(Post.created_at.desc())\
.paginate(page=page, per_page=10, error_out=False)
return render_template('tag.html', tag=tag, posts=posts)
11. 依赖文件 (requirements.txt)¶
Flask==3.1.0
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
Flask-Login==0.6.3
Flask-WTF==1.2.2
Flask-RESTX==1.3.0
WTForms==3.2.1
email-validator==2.2.0
python-dotenv==1.0.1
Pillow==11.1.0
unidecode==1.3.8
12. 环境变量文件 (.env.example)¶
SECRET_KEY=your-secret-key-here
DATABASE_URL=sqlite:///blog.db
MAIL_SERVER=smtp.gmail.com
MAIL_PORT=587
MAIL_USE_TLS=True
MAIL_USERNAME=your-email@gmail.com
MAIL_PASSWORD=your-app-password
ADMIN_EMAIL=admin@blog.com
🚀 部署说明¶
1. 本地部署¶
步骤1: 克隆项目¶
步骤2: 创建虚拟环境¶
python -m venv venv
# 激活虚拟环境
# Windows
venv\Scripts\activate
# Linux/Mac
source venv/bin/activate
步骤3: 安装依赖¶
步骤4: 配置环境变量¶
步骤5: 初始化数据库¶
步骤6: 运行应用¶
2. Docker部署¶
# 构建镜像
docker build -t blog-system .
# 运行容器
docker run -d \
--name blog \
-p 5000:5000 \
-e SECRET_KEY=your-secret-key \
-e DATABASE_URL=postgresql://user:password@db:5432/blog \
blog-system
3. 云部署¶
Heroku¶
- 创建Heroku应用
- 添加PostgreSQL插件
- 推送代码到Heroku
- 配置环境变量
其他云平台¶
- AWS: 使用Elastic Beanstalk或EC2
- Google Cloud: 使用App Engine
- Azure: 使用App Service
🔧 扩展方向¶
1. 功能扩展¶
- Markdown支持: 支持Markdown编辑
- 代码高亮: 代码语法高亮
- 图片上传: 多图上传和管理
- 文章归档: 按月份归档
- RSS订阅: RSS/Atom订阅
- 社交分享: 社交媒体分享
- 文章推荐: 相关文章推荐
2. 性能优化¶
- 缓存: Redis缓存
- CDN: 静态资源CDN
- 数据库优化: 索引优化
- 异步任务: Celery异步任务
3. 用户体验¶
- 暗黑模式: 主题切换
- 即时搜索: 实时搜索
- 无限滚动: 无限加载
- 阅读进度: 阅读进度条
4. 企业功能¶
- 多用户: 多用户博客
- 权限管理: 细粒度权限
- 内容审核: 内容审核系统
- 数据分析: 访问统计
📚 学习收获¶
完成本项目后,你将掌握:
- Flask框架: 熟练使用Flask开发Web应用
- 数据库设计: SQLAlchemy ORM使用
- 用户认证: Flask-Login用户认证
- 表单处理: WTForms表单验证
- RESTful API: API设计和实现
- 前端开发: Bootstrap响应式设计
- 项目部署: 应用部署和运维
🎉 开始学习¶
现在你已经了解了整个个人博客系统的实现,开始动手构建你自己的博客系统吧!
推荐学习顺序: 1. 先实现基本的数据模型和数据库 2. 然后添加用户认证功能 3. 接着实现文章的CRUD功能 4. 最后添加评论、搜索等高级功能
祝你学习顺利! 💪