项目概览:好记星博客系统
"好记星"是一个个人博客系统,今天的目标是搭好最小可运行版本:
- 登录后可以看博客列表 / 写新博客
- 未登录的访客被拦在门外
💡 这章为什么用 SQLite 而不是 MySQL
教材原文用 MySQL,本节课改用 SQLite——零配置、自带于 Python,省掉装数据库 + 配账号那一通麻烦事,下课前必须看见效果。下节课再切到 MySQL 做完整项目。
整个项目用到的依赖
$ pip3 install flask -i https://pypi.tuna.tsinghua.edu.cn/simple
1 目录
建项目目录
$ mkdir -p /home/notebook/templates
$ cd /home/notebook
$ touch app.py db.py init_db.py
$ touch templates/login.html templates/list.html templates/create.html
最终长这样
/home/notebook/ # 项目根目录
├── app.py # ★ Flask 主程序:路由 + 视图
├── db.py # ★ 数据库操作(增删改查)
├── init_db.py # 一次性脚本:建表 + 灌测试数据
├── notebook.db # SQLite 数据库文件(运行 init_db 后产生)
└── templates/ # 模板文件夹
├── login.html # 登录页
├── list.html # 博客列表
└── create.html # 写博客
💬 为什么把数据库操作单独放 db.py
实际项目里,路由 / 视图(app.py)和数据库逻辑(db.py)分两层。这样视图函数变得很短,DB 逻辑可以单独测。这个分层是写出"能维护"代码的第一步。
2 数据库
建表:用户表 + 文章表
db.py:封装 4 个常用函数
# ============ db.py:数据库操作的薄封装 ============
import sqlite3
DB_PATH = "notebook.db" # 数据库文件名(项目根目录下)
def get_conn():
# 拿一个连接。row_factory = Row 让查询返回的行能用 dict-like 访问
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# ============ 用户相关 ============
def find_user(username, password):
# 校验账号密码——返回行或 None
conn = get_conn()
user = conn.execute(
"SELECT * FROM users WHERE username = ? AND password = ?",
(username, password) # ★ 用 ? 占位符,杜绝 SQL 注入
).fetchone()
conn.close()
return user
# ============ 文章相关 ============
def list_articles():
# 列出所有文章(最新的在最上面)
conn = get_conn()
rows = conn.execute(
"SELECT * FROM articles ORDER BY created DESC"
).fetchall()
conn.close()
return rows
def add_article(title, content, author):
# 写入一篇新文章
conn = get_conn()
conn.execute(
"INSERT INTO articles (title, content, author) VALUES (?, ?, ?)",
(title, content, author)
)
conn.commit() # ★ 写操作记得 commit!
conn.close()
⚠️ SQL 占位符 = 防注入第一道墙
永远用 ? 占位符传参,千万别用字符串拼接(f"WHERE name='{name}'")——那样用户输入 ' OR 1=1 -- 就能绕过校验拿走所有数据。
init_db.py:一键建表 + 灌测试数据
# ============ init_db.py:初始化数据库(只跑一次) ============
import sqlite3
conn = sqlite3.connect("notebook.db")
c = conn.cursor()
# 用户表:极简,3 个字段
c.execute("DROP TABLE IF EXISTS users")
c.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)
""")
# 文章表:标题、正文、作者、自动时间戳
c.execute("DROP TABLE IF EXISTS articles")
c.execute("""
CREATE TABLE articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
author TEXT NOT NULL,
created DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
# 插一个测试用户
c.execute("INSERT INTO users (username, password) VALUES (?, ?)",
("admin", "123456"))
# 插两篇测试文章
c.execute("INSERT INTO articles (title, content, author) VALUES (?, ?, ?)",
("第一篇博客", "这是 Flask 博客的第一篇文章。", "admin"))
c.execute("INSERT INTO articles (title, content, author) VALUES (?, ?, ?)",
("学习笔记", "今天学了 Flask 的 Session 机制。", "admin"))
conn.commit()
conn.close()
print("✅ 数据库初始化完成:notebook.db")
$ python3 init_db.py
✅ 数据库初始化完成:notebook.db
3 登录
实际项目里写在环境变量里,不要提交到 git。
app.py:登录 + Session + "门卫"装饰器
这是项目最重要的一文件。先把整个 app.py 给出来,再分块讲:
# ============ app.py:Flask 主程序 ============
from functools import wraps
from flask import (
Flask, request, session,
render_template, redirect, url_for
)
import db
app = Flask(__name__)
# Session 必须设 SECRET_KEY,否则 Flask 拒绝写 session
# 实际项目里要用环境变量,课堂演示就硬编码
app.secret_key = "notebook-2026-secret"
# ============ "门卫"装饰器:要求登录 ============
# 用了它的视图,没登录的用户会被踢回登录页
def login_required(f):
@wraps(f)
def wrapper(*args, **kwargs):
if "username" not in session:
return redirect(url_for("login"))
return f(*args, **kwargs)
return wrapper
# ============ 路由 1:登录 ============
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form["username"]
password = request.form["password"]
user = db.find_user(username, password)
if user:
# 登录成功:把用户名写进 session
session["username"] = username
return redirect(url_for("article_list"))
return render_template("login.html",
error="用户名或密码错误")
return render_template("login.html")
# ============ 路由 2:退出 ============
@app.route("/logout")
def logout():
session.clear() # 清空所有 session
return redirect(url_for("login"))
# ============ 路由 3:文章列表(要登录) ============
@app.route("/")
@login_required
def article_list():
articles = db.list_articles()
return render_template("list.html",
articles=articles,
username=session["username"])
# ============ 路由 4:写新博客(要登录) ============
@app.route("/create", methods=["GET", "POST"])
@login_required
def create():
if request.method == "POST":
title = request.form["title"]
content = request.form["content"]
db.add_article(title, content, session["username"])
return redirect(url_for("article_list"))
return render_template("create.html")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
💬 这里有个新东西:装饰器 login_required
不想每个视图都重复写"先看 session",就写成装饰器:@login_required 一行就替原视图加上"门卫"。functools.wraps 是必备搭配——让原函数的名字、文档不被吃掉。固定写法记住即可。
⚠️ SECRET_KEY 不能省
Flask 用它给 session cookie 签名(防伪造)。不设就用不了 session,会报错。实际项目里写在环境变量里,不要提交到 git。
4 模板
登录页 + 文章列表页
templates/login.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"><title>好记星 · 登录</title>
<style>
body { font-family: sans-serif;
max-width: 360px; margin: 80px auto; }
input { width:100%; padding:8px; margin:6px 0;
box-sizing:border-box; }
button { width:100%; padding:10px;
background:#3B6B9A; color:#fff;
border:none; cursor:pointer; }
.err { color:#B83B2E; }
</style>
</head>
<body>
<h2>📝 好记星 · 登录</h2>
<form method="post">
<input name="username" placeholder="用户名">
<input name="password" type="password" placeholder="密码">
<button>登录</button>
</form>
{% if error %}<p class="err">❌ {{ error }}</p>{% endif %}
<p style="color:#888">课堂账号:admin / 123456</p>
</body>
</html>
💡 Flask 不强制 csrf_token
Flask 默认不开 CSRF 防护(要开得装 Flask-WTF)。课堂演示就裸 form 提交。
templates/list.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"><title>好记星 · 我的博客</title>
<style>
body { font-family: sans-serif; max-width: 720px;
margin: 30px auto; padding: 0 20px; line-height: 1.7; }
article { padding: 14px 0; border-bottom: 1px solid #eee; }
.meta { color: #888; font-size: 0.9em; }
.topbar { display:flex; justify-content:space-between;
align-items:center; margin-bottom: 12px; }
.btn { background:#D97B2B; color:#fff; padding:6px 12px;
border-radius:4px; text-decoration:none; }
</style>
</head>
<body>
<div class="topbar">
<h1>📝 我的博客</h1>
<span>
👋 {{ username }} |
<a href="/create" class="btn">➕ 写新文章</a> |
<a href="/logout">退出</a>
</span>
</div>
<!-- 循环:list_articles 视图传过来的 articles -->
{% for a in articles %}
<article>
<h2>{{ a.title }}</h2>
<div class="meta">
{{ a.author }} · {{ a.created }}
</div>
<p>{{ a.content }}</p>
</article>
{% else %}
<p>还没有文章。点上面"写新文章"添一篇吧。</p>
{% endfor %}
</body>
</html>
💬 Jinja 里 for 也能 else
{% for ... %}{% else %}{% endfor %}——循环为空时走 else。比写 {% if articles %} 包一层更优雅。
5 写博客
新建博客的页面
templates/create.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"><title>写新文章</title>
<style>
body { font-family: sans-serif;
max-width: 600px; margin: 30px auto; }
input, textarea { width:100%; padding:8px;
margin: 4px 0 12px; box-sizing:border-box; }
textarea { height: 200px; }
button { padding: 8px 18px; background:#3B6B9A;
color:#fff; border:none; cursor:pointer; }
</style>
</head>
<body>
<h2>✍️ 写一篇新文章</h2>
<form method="post">
<label>标题:<input name="title" required></label>
<label>正文:<textarea name="content" required></textarea></label>
<button>发布</button>
<a href="/">返回列表</a>
</form>
</body>
</html>
跑起来
$ cd /home/notebook
# 数据库还没建过的同学先跑一次:
$ python3 init_db.py
# 启动 Flask:
$ python3 app.py
🎉 整体流程
http://IP:5000/ → 自动跳转到登录页(因为门卫拦了)
用 admin / 123456 登录 → 看到文章列表 → 点"写新文章" → 提交 → 列表里多一篇
点"退出" → 又回到登录页
🎯 项目骨架的"三层"
templates/
←
app.py 视图
←
db.py 数据访问
←
SQLite
每一层只做自己的事——这就是分层架构,所有像样的项目都长这样。
📋 本节小结
路由表
| 路由 | 方法 | 作用 | 需登录 |
|---|---|---|---|
/login | GET / POST | 登录 | 否 |
/logout | GET | 退出 | 否 |
/ | GET | 文章列表 | 是 |
/create | GET / POST | 写文章 | 是 |
下节课要做的事
- 把 SQLite 换成 MySQL(教材原版)
- 密码用 hashlib / passlib 哈希存储(不能裸存)
- 用 WTForms 做表单校验
- 用 CKEditor 做富文本编辑
- 编辑文章 / 删除文章
课堂练习
两组共 12 题:先项目结构 / Flask Session,再 SQL 和安全。
第一组
项目结构 + Session(6 题)
Q1单选
本项目把数据库操作单独放在 db.py 里,这种做法叫?
解析:每层只做一类事,互不干扰——这是写"能维护"项目的基本功。
Q2单选
Flask 中要使用 session,必须先做哪件事?
解析:Flask 默认把 session 数据签名后写在 cookie 里,签名需要 SECRET_KEY。不设就报错。
Q3单选
@login_required 装饰器干了什么?
解析:装饰器在不动原函数的前提下"前后包一层"。把"先检查登录态"这种重复逻辑抽出来,避免每个视图都写一遍。
Q4单选
session.clear() 等价于:
解析:session 在 Flask 里是个特殊字典,clear 把它清空,登录态就没了。
Q5判断
init_db.py 这种"建表 + 灌测试数据"的脚本应该跑很多次。
解析:它是"一次性"脚本——每次跑都会 DROP 重建,已有数据会全没。线上千万别误跑。
Q6判断
SQLite 每次写完操作都需要 conn.commit(),否则改动不会落到磁盘。
解析:这是新人常掉的坑——insert 跑完没提交,重启后数据没了。SELECT 不需要 commit。
第二组
SQL 与安全(6 题)
Q1单选
下面哪种写法有 SQL 注入风险?
解析:字符串拼接 SQL = 永远的禁忌。用占位符
? 让数据库驱动帮你转义,绝对安全。
Q2单选
如果 init_db 里写 password TEXT NOT NULL,但上线项目对密码字段最不能省的处理是?
解析:明文存密码 = 数据库一泄漏所有用户密码全暴露。base64 不是加密只是编码。哈希 + 盐才是行业标准。
Q3单选
SQL ORDER BY created DESC 是什么意思?
解析:ASC = 升序(默认),DESC = 降序。文章列表都用 DESC,让最新的在最上面。
Q4单选
Jinja 模板里循环若集合为空想显示提示文字,应该用?
解析:Jinja 的
for...else 专门为这种场景设计——循环为空时走 else。比 if 包一层更优雅。A 也能实现但不是首选。
Q5单选
用户访问 / 没登录被拦,到底是哪行代码起作用?
解析:装饰器在视图执行前检查 session——是它把没登录的用户重定向到登录页的。
Q6判断
本项目把账号密码写在数据库里(明文)只是课堂演示。实际项目里这是严重错误,密码必须哈希存储。
解析:明文存密码 = 数据库泄漏后所有人都被攻陷。生产代码必须用 bcrypt / argon2 + 盐。下节课给它升级。