实战项目 - 博客系统¶
📖 项目简介¶
本章将通过构建一个完整的博客系统,综合运用前面学到的Flask知识。这个项目将包含用户管理、文章发布、评论系统等完整功能。
🎯 项目目标¶
完成本项目后,你将能够: - 构建完整的Flask Web应用 - 实现用户认证和授权 - 掌握数据库设计和操作 - 实现表单处理和验证 - 构建RESTful API - 掌握应用部署和运维
🏗️ 项目结构¶
Text Only
blog_system/
├── app/
│ ├── __init__.py # 应用工厂
│ ├── models.py # 数据模型
│ ├── forms.py # 表单定义
│ ├── auth/ # 认证模块
│ │ ├── __init__.py
│ │ ├── routes.py
│ │ └── forms.py
│ ├── main/ # 主模块
│ │ ├── __init__.py
│ │ └── routes.py
│ ├── post/ # 文章模块
│ │ ├── __init__.py
│ │ ├── routes.py
│ │ └── forms.py
│ ├── api/ # API模块
│ │ ├── __init__.py
│ │ └── routes.py
│ ├── templates/ # 模板文件
│ │ ├── base.html
│ │ ├── index.html
│ │ ├── auth/
│ │ └── post/
│ └── static/ # 静态文件
│ ├── css/
│ ├── js/
│ └── images/
├── migrations/ # 数据库迁移
├── tests/ # 测试文件
├── config.py # 配置文件
├── requirements.txt # 依赖列表
├── .env # 环境变量
├── .gitignore # Git忽略文件
├── Dockerfile # Docker配置
├── docker-compose.yml # Docker Compose配置
└── wsgi.py # WSGI入口
📄 核心功能实现¶
1. 数据模型¶
Python
# app/models.py
from datetime import datetime, timezone
from app import db
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
class User(UserMixin, db.Model):
"""用户模型 - 继承UserMixin提供Flask-Login所需的认证方法"""
__tablename__ = 'users'
# === 基本字段 ===
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(20), unique=True, nullable=False, index=True) # 用户名,建立索引加速查询
email = db.Column(db.String(120), unique=True, nullable=False, index=True) # 邮箱,唯一约束防止重复注册
password_hash = db.Column(db.String(128)) # 存储哈希后的密码,不保存明文
bio = db.Column(db.Text) # 个人简介
avatar = db.Column(db.String(200)) # 头像URL
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)) # 注册时间,使用UTC时区
# === 关系定义 ===
# lazy='dynamic' 返回查询对象而非直接加载,适合数据量大的一对多关系
# cascade='all, delete-orphan' 删除用户时级联删除其所有文章和评论
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):
"""使用werkzeug生成密码哈希,单向加密不可逆"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""验证密码是否匹配哈希值"""
return check_password_hash(self.password_hash, password)
def to_dict(self):
return {
'id': self.id,
'username': self.username,
'email': self.email,
'bio': self.bio,
'avatar': self.avatar,
'created_at': self.created_at.isoformat()
}
class Post(db.Model):
"""文章模型 - 博客系统核心实体"""
__tablename__ = 'posts'
# === 基本字段 ===
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False) # 文章标题
content = db.Column(db.Text, nullable=False) # 文章正文,使用Text类型存储长文本
slug = db.Column(db.String(120), unique=True, nullable=False, index=True) # URL友好的文章标识(如:my-first-post)
summary = db.Column(db.String(200)) # 文章摘要,用于列表页展示
cover_image = db.Column(db.String(200)) # 封面图片路径
is_published = db.Column(db.Boolean, default=False) # 发布状态,默认为草稿
view_count = db.Column(db.Integer, default=0) # 浏览计数器
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)) # 更新时间,onupdate自动更新
# === 外键 - 建立与用户和分类的关联 ===
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # 作者ID,不可为空
category_id = db.Column(db.Integer, db.ForeignKey('categories.id')) # 分类ID,可为空
# === 关系定义 ===
comments = db.relationship('Comment', backref='post', lazy='dynamic', cascade='all, delete-orphan')
# 多对多关系:通过post_tags中间表关联标签
tags = db.relationship('Tag', secondary='post_tags', backref=db.backref('posts', lazy='dynamic'))
def to_dict(self):
return {
'id': self.id,
'title': self.title,
'content': self.content,
'slug': self.slug,
'summary': self.summary,
'cover_image': self.cover_image,
'is_published': self.is_published,
'view_count': self.view_count,
'created_at': self.created_at.isoformat(),
'author': self.author.to_dict() if self.author else None,
'category': self.category.to_dict() if self.category else None
}
class Comment(db.Model):
"""评论模型"""
__tablename__ = 'comments'
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) # lambda匿名函数:简洁的单行函数
# 外键
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False)
def to_dict(self):
return {
'id': self.id,
'content': self.content,
'created_at': self.created_at.isoformat(),
'author': self.author.to_dict() if self.author else None
}
class Category(db.Model):
"""分类模型"""
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
slug = db.Column(db.String(60), unique=True, nullable=False)
# 关系
posts = db.relationship('Post', backref='category', lazy='dynamic')
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'description': self.description,
'slug': self.slug
}
class Tag(db.Model):
"""标签模型"""
__tablename__ = 'tags'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(30), unique=True, nullable=False)
slug = db.Column(db.String(30), unique=True, nullable=False)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'slug': self.slug
}
# 多对多关系表 - 文章与标签的关联表
# 使用联合主键(post_id + tag_id)确保同一文章不会重复关联同一标签
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) # 关联标签
)
2. 用户认证¶
Python
# app/auth/routes.py
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, current_user
from app.auth.forms import LoginForm, RegistrationForm
from app.models import User
from app import db
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
# 已登录用户直接跳转首页,避免重复登录
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = LoginForm()
# validate_on_submit() 同时检查是否为POST请求和表单验证是否通过
if form.validate_on_submit():
# 根据用户名查询用户记录
user = User.query.filter_by(username=form.username.data).first()
if user and user.check_password(form.password.data):
# 检查账号是否被禁用
if not user.is_active:
flash('账号已被禁用', 'error')
return render_template('auth/login.html', form=form)
# Flask-Login的login_user:将用户ID写入session
# remember参数控制是否生成"记住我"的持久cookie
login_user(user, remember=form.remember_me.data)
flash('登录成功!', 'success')
# 安全重定向:获取登录前的目标页面
# 检查next参数是否以'/'开头,防止开放重定向攻击
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('main.index')
return redirect(next_page)
else:
flash('用户名或密码错误', 'error')
return render_template('auth/login.html', 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', form=form)
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
flash('您已退出登录', 'info')
return redirect(url_for('main.index'))
3. 文章管理¶
Python
# app/post/routes.py
from flask import render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user
from app.post.forms import PostForm, CommentForm
from app.models import Post, Comment
from app import db
from app.utils import save_upload_file, generate_slug
@post_bp.route('/create', methods=['GET', 'POST'])
@login_required # 装饰器确保只有登录用户才能创建文章
def create_post():
form = PostForm()
if form.validate_on_submit():
# 创建文章实例,generate_slug将标题转换为URL友好格式
post = Post(
title=form.title.data,
content=form.content.data,
slug=generate_slug(form.title.data),
summary=form.summary.data,
author=current_user, # 自动关联当前登录用户为作者
is_published=form.is_published.data
)
# 处理封面图片
if form.cover_image.data:
filename = save_upload_file(form.cover_image.data, 'covers')
if filename:
post.cover_image = filename
# 处理标签
tags = form.tags.data.split(',')
for tag_name in tags:
tag_name = tag_name.strip()
if tag_name:
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.add(post)
db.session.commit()
flash('文章创建成功!', 'success')
return redirect(url_for('post.detail', slug=post.slug))
return render_template('post/create.html', form=form)
@post_bp.route('/<slug>')
def detail(slug):
post = Post.query.filter_by(slug=slug).first_or_404()
# 增加浏览次数
post.view_count += 1
db.session.commit()
form = CommentForm()
return render_template('post/detail.html', post=post, form=form)
@post_bp.route('/<slug>/edit', methods=['GET', 'POST'])
@login_required
def edit_post(slug):
# first_or_404(): 查找文章,不存在时自动返回404响应
post = Post.query.filter_by(slug=slug).first_or_404()
# 权限检查:只有文章作者或管理员才能编辑
if post.author != current_user and not current_user.is_admin:
abort(403) # 返回403禁止访问
# obj=post 将现有文章数据填充到表单中
form = PostForm(obj=post)
if form.validate_on_submit():
post.title = form.title.data
post.content = form.content.data
post.summary = form.summary.data
post.is_published = form.is_published.data
# 处理封面图片
if form.cover_image.data:
filename = save_upload_file(form.cover_image.data, 'covers')
if filename:
post.cover_image = filename
# 更新标签
post.tags.clear()
tags = form.tags.data.split(',')
for tag_name in tags:
tag_name = tag_name.strip()
if tag_name:
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', form=form, post=post)
@post_bp.route('/<slug>/comment', methods=['POST'])
@login_required
def add_comment(slug):
"""添加评论 - 仅接受POST请求"""
post = Post.query.filter_by(slug=slug).first_or_404()
form = CommentForm()
if form.validate_on_submit():
# 创建评论并关联当前用户和目标文章
comment = Comment(
content=form.content.data,
author=current_user, # 评论作者
post=post # 所属文章
)
db.session.add(comment)
db.session.commit()
flash('评论发布成功!', 'success')
return redirect(url_for('post.detail', slug=slug))
4. RESTful API¶
Python
# app/api/routes.py
from flask import jsonify, request
from app.models import Post, User, Comment
from app import db
@api_bp.route('/posts', methods=['GET'])
def get_posts():
"""获取文章列表 - 支持分页、分类和标签过滤"""
# 从URL查询参数中获取分页和过滤条件,type=int自动转换类型
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
category = request.args.get('category')
tag = request.args.get('tag')
# 基础查询:只返回已发布的文章
query = Post.query.filter_by(is_published=True)
# 链式查询构建:根据条件动态添加过滤器
if category:
query = query.join(Category).filter(Category.slug == category) # 按分类过滤
if tag:
query = query.join(Tag).filter(Tag.slug == tag) # 按标签过滤
# 分页查询:按创建时间倒序排列,error_out=False防止页码越界时抛异常
pagination = query.order_by(Post.created_at.desc())\
.paginate(page=page, per_page=per_page, error_out=False)
# 将ORM对象列表转换为字典列表,便于JSON序列化
posts = [post.to_dict() for post in pagination.items]
# 返回文章数据和分页元信息
return jsonify({
'posts': posts,
'total': pagination.total, # 总文章数
'pages': pagination.pages, # 总页数
'current_page': page # 当前页码
})
@api_bp.route('/posts/<slug>', methods=['GET'])
def get_post(slug):
"""获取单篇文章"""
post = Post.query.filter_by(slug=slug, is_published=True).first_or_404()
return jsonify(post.to_dict())
@api_bp.route('/posts/<slug>/comments', methods=['GET'])
def get_post_comments(slug):
"""获取文章评论"""
post = Post.query.filter_by(slug=slug, is_published=True).first_or_404()
comments = [comment.to_dict() for comment in post.comments.order_by(Comment.created_at.desc()).all()]
return jsonify({'comments': comments})
🚀 部署配置¶
1. Docker配置¶
Docker
# Dockerfile
FROM python:3.12-slim # FROM指定基础镜像
WORKDIR /app # WORKDIR设置工作目录
# 安装系统依赖
RUN apt-get update && apt-get install -y \ # RUN在构建时执行命令
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件
COPY requirements.txt . # COPY将文件复制到镜像中
# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码
COPY . .
# 创建非root用户
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app
USER appuser
# 暴露端口
EXPOSE 8000 # EXPOSE声明容器监听的端口
# 启动应用
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "wsgi:app"] # CMD容器启动时执行的默认命令
YAML
# docker-compose.yml - 多容器编排配置
services: # services定义各个服务容器
# Flask应用服务 - 通过Gunicorn运行
web:
build: .
ports:
- "8000:8000" # 映射主机8000端口到容器8000端口
environment:
- DATABASE_URL=postgresql://user:password@db:5432/blog # 数据库连接串,db为服务名自动解析
- SECRET_KEY=${SECRET_KEY} # 从.env文件读取密钥
depends_on:
- db # 确保数据库先启动
- redis # 确保Redis先启动
volumes:
- ./uploads:/app/uploads # 挂载上传目录,持久化用户上传的文件
restart: unless-stopped # 容器异常退出时自动重启
# PostgreSQL数据库服务
db:
image: postgres:15
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=blog
volumes:
- postgres_data:/var/lib/postgresql/data # 命名卷持久化数据库数据
restart: unless-stopped
# Redis缓存服务 - 用于会话存储和缓存
redis:
image: redis:7-alpine # 使用Alpine轻量镜像减小体积
restart: unless-stopped
# Nginx反向代理 - 处理HTTPS和静态文件
nginx:
image: nginx:alpine
ports:
- "80:80" # HTTP端口
- "443:443" # HTTPS端口
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf # 自定义Nginx配置
- ./ssl:/etc/nginx/ssl # SSL证书目录
depends_on:
- web # 确保应用服务先启动
restart: unless-stopped
volumes:
postgres_data:
📝 项目总结¶
完成的功能¶
- ✅ 用户注册和登录
- ✅ 文章发布和管理
- ✅ 评论系统
- ✅ 分类和标签
- ✅ RESTful API
- ✅ 图片上传
- ✅ 搜索功能
- ✅ 分页显示
- ✅ 用户权限控制
- ✅ Docker部署
学到的知识¶
通过这个项目,你实践了: - Flask应用工厂模式 - 蓝图组织代码 - 数据库设计和ORM - 表单处理和验证 - 用户认证和授权 - RESTful API设计 - 文件上传处理 - Docker容器化部署
扩展建议¶
你可以继续扩展这个博客系统: - 添加富文本编辑器 - 实现文章点赞功能 - 添加社交分享 - 实现邮件通知 - 添加全文搜索 - 实现多语言支持 - 添加后台管理界面 - 集成第三方登录 - 添加数据分析 - 实现缓存优化
📚 推荐资源¶
🎉 恭喜完成!¶
你已经成功构建了一个完整的Flask博客系统!这个项目展示了Flask Web开发的核心概念和最佳实践。继续学习和实践,你将能够构建更复杂、更强大的Web应用。