缘起
每次换笔记工具都折腾好久,最终决定自己写一个。目标很明确:
- 用 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)
设计要点:
- Tags 存为 JSON 字符串 —— 用
LIKE '%"tag"%'检索,避免多表 JOIN - Slug 唯一约束 —— URL 友好的文章标识
- 外键级联删除 —— 删文章自动清理评论
- 排序字段 —— 项目和链接支持手动调整顺序
核心功能实现
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。
上线前安全自检
debug=False(生产默认关闭,仅FLASK_DEBUG=1时开启)secret_key来自环境变量,不硬编码- 头像上传白名单移除
.svg,杜绝存储型 XSS admin_project_edit/admin_link_edit对不存在记录返回 404,不崩溃- 注册 404 / 500 错误页,避免泄露调试信息
- 经
ProxyFix获取真实客户端 IP,防伪造
设计理念
1. 极简依赖
仅依赖 flask 与 markdown(部署再加 gunicorn),不需要装一堆东西。
2. 数据层封装
所有数据库操作统一为 db_* 函数,路由层不直接写 SQL。
3. 单文件可部署
核心是 app.py + templates/ + static/,无复杂构建流程。
4. 渐进增强
从 JSON 文件存储迁移到 SQLite,从基础博客扩展到项目管理 + 评论 + RSS。
总结
用 Flask + SQLite 自建博客的好处:
- 完全可控 —— 每一行代码都清楚
- 极简部署 —— gunicorn 一条命令,Nginx 反代即可上线
- 数据自主 —— 一个 blog.db 文件,随时备份
- 持续迭代 —— 想加什么功能自己写
一个麻雀虽小五脏俱全的博客系统。如果你也在寻找一个轻量级的博客方案,infowe.site 也许是个不错的起点。
评论功能已关闭