infowe.site:用 Flask + SQLite 手搓一个完整博客系统

缘起

每次换笔记工具都折腾好久,最终决定自己写一个。目标很明确:

  • 用 Markdown 写文章
  • 代码干净,可以长期维护
  • 带后台管理,别每次都去改数据库
  • 能展示 GitHub 开源项目
  • 部署简单,一条命令跑起来

于是有了 infowe.site —— 一个用 Flask + SQLite 手搓的极简博客系统。整个项目不到一千行 Python,却能跑起一个麻雀虽小五脏俱全的个人站点。


技术栈一览

层次 选型 理由
Web 框架 Flask 极简、灵活、Python 生态好
数据库 SQLite 零配置、单文件部署、够用
模板引擎 Jinja2 Flask 内置、继承机制好用
Markdown Python-Markdown 支持栅栏代码块、表格、SaneLists
前端样式 手写 CSS 无框架依赖,加载快
图标 Lucide Icons SVG 图标、统一风格、按需引入
部署 Nginx + gunicorn 生产级 WSGI,稳定可守护

项目结构

infowe.site/
├── app.py              # 主程序(入口:app = Flask(__name__))
├── blog.db             # SQLite 数据库(自动生成)
├── posts/              # 文章 Markdown 源文件(历史遗留,正文已入库)
├── templates/
│   ├── base.html       # 前台基础模板
│   ├── index.html      # 首页(搜索 + 分页 + 精选)
│   ├── post.html       # 文章详情(评论 + 相关推荐)
│   ├── projects.html   # 开源项目展示页
│   ├── tags.html       # 标签云
│   ├── links.html      # 友情链接
│   ├── about.html      # 关于页(技能条 + 管理员头像)
│   ├── 404.html        # 站内 404 页
│   ├── 500.html        # 站内 500 页
│   └── admin/          # 后台模板(登录 + 仪表盘 + 文章/项目/友链/评论/设置)
├── static/
│   ├── css/  style.css / admin.css
│   ├── js/   粒子特效、编辑器、预览等
│   └── icons/  Lucide SVG 图标
├── gunicorn.conf.py    # 生产 WSGI 配置
├── deploy_infowe.site.conf  # Nginx 反代配置
├── blog.service        # systemd 服务单元
├── DEPLOY.md / DEPLOY_BT.md # 命令行 / 宝塔部署文档
└── requirements.txt    # 依赖:flask, markdown, gunicorn

数据库设计(6 张表)

-- 文章表(核心)
posts (
    id, title, slug UNIQUE, content, excerpt,
    tags JSON, cover, read_time,
    is_featured, status, created_at, updated_at, category_id
)

-- 开源项目表
projects (
    id, name, description, url,
    stars, language, topics JSON,
    sort_order, featured
)

-- 友情链接表
links (
    id, name, url, description, sort_order
)

-- 评论表
comments (
    id, post_id FK→posts, author, content, created_at
)

-- 系统设置表
settings (key UNIQUE, value)

-- 用户表
users (username UNIQUE, password_hash)

设计要点:

  1. Tags 存为 JSON 字符串 —— 用 LIKE '%"tag"%' 检索,避免多表 JOIN
  2. Slug 唯一约束 —— URL 友好的文章标识
  3. 外键级联删除 —— 删文章自动清理评论
  4. 排序字段 —— 项目和链接支持手动调整顺序

核心功能实现

1. 文章分页搜索

PAGE_SIZE = 6

def db_load_posts(status='published', tag=None, search=None, page=1, per_page=PAGE_SIZE):
    conditions = []
    params = []

    if status:
        conditions.append("status=?")
        params.append(status)
    if tag:
        conditions.append("tags LIKE ?")
        params.append(f'%"{tag}"%')
    if search:
        conditions.append("(title LIKE ? OR content LIKE ? OR excerpt LIKE ?)")
        s = f'%{search}%'
        params.extend([s, s, s])

    where = "WHERE " + " AND ".join(conditions) if conditions else ""

    total = db.execute(f"SELECT COUNT(*) FROM posts {where}", params).fetchone()['c']

    offset = (page - 1) * per_page
    rows = db.execute(
        f"SELECT * FROM posts {where}
         ORDER BY is_featured DESC, created_at DESC
         LIMIT ? OFFSET ?",
        params + [per_page, offset]
    ).fetchall()
    return posts, total

2. Markdown 渲染

def render_post_content(content):
    content = re.sub(r'^---.*?---\s*', '', content, flags=re.DOTALL)
    return markdown.markdown(
        content,
        extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
    )

3. 相关文章推荐

基于标签匹配的简单推荐算法:

def db_get_related_posts(current_id, tags, limit=3):
    results = []
    for tag in tags:
        rows = db.execute(
            """SELECT * FROM posts
               WHERE status='published' AND id!=?
               AND tags LIKE ? ORDER BY created_at DESC LIMIT ?""",
            (current_id, f'%"{tag}"%', limit)
        ).fetchall()
        for r in rows:
            if r['id'] not in [x['id'] for x in results]:
                results.append(r)
        if len(results) >= limit:
            break
    return results[:limit]

4. RSS 订阅源

用标准库 xml.etree.ElementTree 手写 XML,零依赖:

@app.route('/feed.xml')
def rss_feed():
    posts, _ = db_load_posts(per_page=20)
    rss = Element('rss', version='2.0')
    channel = SubElement(rss, 'channel')
    SubElement(channel, 'title').text = blog_name
    SubElement(channel, 'link').text = request.url_root
    for p in posts:
        item = SubElement(channel, 'item')
        SubElement(item, 'title').text = p['title']
        SubElement(item, 'link').text = f"{request.url_root}post/{p['slug']}"
    return app.response_class(xml_str, mimetype='application/rss+xml')

5. 密码安全(已加固)

早期版本用 hashlib.sha256 加盐哈希,已被替换为 werkzeug 的安全哈希(pbkdf2 + 随机盐 + 多次迭代),抗暴力破解能力强得多。同时 secret_key 改为从环境变量读取:

import os
from werkzeug.security import generate_password_hash

# secret_key 优先取自环境变量,避免源码硬编码导致 session 被伪造
app.secret_key = os.environ.get('BLOG_SECRET_KEY', 'dev-fallback-key')

def hash_password(password):
    return generate_password_hash(password, method='pbkdf2:sha256', salt_length=16)

生产部署务必设置强随机的 BLOG_SECRET_KEY(如 python -c "import secrets;print(secrets.token_hex(32))")。

6. 数据库兼容迁移

# 兼容旧数据库:添加新列(不删库不重建)
try:
    db.execute("SELECT is_featured FROM posts LIMIT 1")
except sqlite3.OperationalError:
    db.execute("ALTER TABLE posts ADD COLUMN is_featured INTEGER DEFAULT 0")

这种方式比删库重建安全得多,保留用户已有数据。


后台管理系统

路由总览(约 48 个端点)

路由 方法 功能
/admin/login GET/POST 登录(含粒子背景 + 亮暗主题切换)
/admin/dashboard GET 统计仪表盘
/admin/posts GET 文章列表(搜索/分页/筛选)
/admin/posts/new GET/POST 新建文章
/admin/posts/<id>/edit GET/POST 编辑文章
/admin/posts/<id>/delete POST 删除文章
/admin/posts/<id>/preview GET AJAX 预览
/admin/projects GET 项目列表
/admin/projects/new GET/POST 添加项目
/admin/projects/<id>/edit GET/POST 编辑项目
/admin/links GET 链接列表
/admin/links/new GET/POST 添加链接
/admin/comments GET 评论管理
/admin/comments/<id>/delete POST 删除评论
/admin/settings GET/POST 博客设置(账号/头像/密码/站点信息/博主资料/关于页/备案)

后台分为左右两栏卡片式布局:管理员(账号 + 头像 + 新密码 + 联系邮箱)、站点信息(名称/副标题/首页标题/文章数/每页条数/评论开关)、博主资料(名称/简介/GitHub)、关于页面(介绍/技能)、备案信息(ICP/公安备案)。

登录鉴权

使用 session + 装饰器模式:

def admin_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if not session.get('admin_logged_in'):
            return redirect(url_for('admin_login'))
        return f(*args, **kwargs)
    return decorated

真实 IP 获取(Nginx 反代下)

生产环境通过 Nginx 反代访问,需借助 ProxyFix 还原真实客户端 IP,否则日志里只会看到 127.0.0.1

from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)

Nginx 需转发 X-Forwarded-For

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;

x_for=1 表示只信任一层可信代理(Nginx),直接访问本机的伪造 X-Forwarded-For 头不会被采纳,避免 IP 欺骗。


前台页面

首页

  • 文章列表 + 分页器(页码带省略号)
  • 搜索框(标题 / 内容 / 摘要全文检索)
  • 标签筛选
  • 精选文章区域

文章详情

  • Markdown 渲染(代码高亮、表格)
  • 相关文章推荐(基于标签匹配)
  • 评论区(昵称 + 内容,最长 2000 字)
  • 分享按钮 + CC BY-NC-SA 4.0 许可声明

GitHub 项目页

  • 卡片式布局,展示项目名称、描述、语言、Star 数、标签
  • 按语言筛选
  • 语言分布统计

其他

  • 标签云(含文章计数)
  • 友情链接(头像 + 描述卡片)
  • 关于页(技能进度条、个人介绍、GitHub 链接、管理员头像)
  • RSS Feed(/feed.xml)

部署

方式一:命令行 + gunicorn(通用 Linux)

python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt gunicorn
export BLOG_SECRET_KEY="$(python -c 'import secrets;print(secrets.token_hex(32))')"
gunicorn -c gunicorn.conf.py app:app

配合 deploy_infowe.site.conf(Nginx 反代)+ blog.service(systemd 守护)即可稳定运行。

方式二:宝塔面板

在「Python 项目」中添加 Flask 项目(启动文件 app.py、启动对象 app、端口 5000),于「项目设置 → 环境变量」设置 BLOG_SECRET_KEY,再建站点做反向代理到 127.0.0.1:5000,最后用 Let's Encrypt 申请免费 SSL。详细步骤见 DEPLOY_BT.md


上线前安全自检

  1. debug=False(生产默认关闭,仅 FLASK_DEBUG=1 时开启)
  2. secret_key 来自环境变量,不硬编码
  3. 头像上传白名单移除 .svg,杜绝存储型 XSS
  4. admin_project_edit / admin_link_edit 对不存在记录返回 404,不崩溃
  5. 注册 404 / 500 错误页,避免泄露调试信息
  6. ProxyFix 获取真实客户端 IP,防伪造

设计理念

1. 极简依赖

仅依赖 flaskmarkdown(部署再加 gunicorn),不需要装一堆东西。

2. 数据层封装

所有数据库操作统一为 db_* 函数,路由层不直接写 SQL。

3. 单文件可部署

核心是 app.py + templates/ + static/,无复杂构建流程。

4. 渐进增强

从 JSON 文件存储迁移到 SQLite,从基础博客扩展到项目管理 + 评论 + RSS。


总结

用 Flask + SQLite 自建博客的好处:

  1. 完全可控 —— 每一行代码都清楚
  2. 极简部署 —— gunicorn 一条命令,Nginx 反代即可上线
  3. 数据自主 —— 一个 blog.db 文件,随时备份
  4. 持续迭代 —— 想加什么功能自己写

一个麻雀虽小五脏俱全的博客系统。如果你也在寻找一个轻量级的博客方案,infowe.site 也许是个不错的起点。

评论功能已关闭