跳转至

实战项目: 个人博客系统

难度: ⭐⭐⭐⭐ 高级 时间: 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

🏗️ 项目结构

Text Only
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)

Python
"""
配置文件
"""
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)

Python
"""
数据模型
"""
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)

Python
"""
表单定义
"""
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-数据库集成 中的对照示例。

Python
"""
认证路由
"""
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)

Python
"""
文章路由
"""
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)

Python
"""
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)

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">&copy; {{ 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)

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)

Python
"""
应用工厂
"""
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)

Python
"""
主路由
"""
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)

Text Only
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)

Text Only
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: 克隆项目

Bash
git clone https://github.com/yourusername/blog-system.git
cd blog-system

步骤2: 创建虚拟环境

Bash
python -m venv venv

# 激活虚拟环境
# Windows
venv\Scripts\activate
# Linux/Mac
source venv/bin/activate

步骤3: 安装依赖

Bash
pip install -r requirements.txt

步骤4: 配置环境变量

Bash
cp .env.example .env
# 编辑.env文件,填入配置信息

步骤5: 初始化数据库

Bash
flask db init
flask db migrate -m "Initial migration"
flask db upgrade

步骤6: 运行应用

Bash
flask run

2. Docker部署

Bash
# 构建镜像
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

  1. 创建Heroku应用
  2. 添加PostgreSQL插件
  3. 推送代码到Heroku
  4. 配置环境变量

其他云平台

  • 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. 最后添加评论、搜索等高级功能

祝你学习顺利! 💪