第12章 · Flask 开发"好记星"博客

把 Flask 学到的知识攒成第一个完整项目。今天搭骨架——目录结构、SQLite 数据库、登录、文章列表 + 添加。

🎯 浏览器登录 → 看见自己的博客 → 写一篇新文章

项目概览:好记星博客系统

"好记星"是一个个人博客系统,今天的目标是搭好最小可运行版本

  • 登录后可以看博客列表 / 写新博客
  • 未登录的访客被拦在门外
💡 这章为什么用 SQLite 而不是 MySQL
教材原文用 MySQL,本节课改用 SQLite——零配置、自带于 Python,省掉装数据库 + 配账号那一通麻烦事,下课前必须看见效果。下节课再切到 MySQL 做完整项目。

整个项目用到的依赖

SHELL
$ pip3 install flask -i https://pypi.tuna.tsinghua.edu.cn/simple
1 目录

建项目目录

SHELL
$ 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 个常用函数

PYTHON /home/notebook/db.py
# ============ 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:一键建表 + 灌测试数据

PYTHON /home/notebook/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")
SHELL
$ python3 init_db.py
✅ 数据库初始化完成:notebook.db
3 登录

app.py:登录 + Session + "门卫"装饰器

这是项目最重要的一文件。先把整个 app.py 给出来,再分块讲:

PYTHON /home/notebook/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

HTML 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

HTML 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

HTML 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>

跑起来

SHELL
$ cd /home/notebook
# 数据库还没建过的同学先跑一次:
$ python3 init_db.py
# 启动 Flask:
$ python3 app.py

🎉 整体流程

http://IP:5000/ → 自动跳转到登录页(因为门卫拦了)

用 admin / 123456 登录 → 看到文章列表 → 点"写新文章" → 提交 → 列表里多一篇

点"退出" → 又回到登录页

🎯 项目骨架的"三层"
templates/ app.py 视图 db.py 数据访问 SQLite

每一层只做自己的事——这就是分层架构,所有像样的项目都长这样。

📋 本节小结

路由表

路由方法作用需登录
/loginGET / POST登录
/logoutGET退出
/GET文章列表
/createGET / POST写文章

下节课要做的事

  • 把 SQLite 换成 MySQL(教材原版)
  • 密码用 hashlib / passlib 哈希存储(不能裸存)
  • 用 WTForms 做表单校验
  • 用 CKEditor 做富文本编辑
  • 编辑文章 / 删除文章

课堂练习

两组共 12 题:先项目结构 / Flask Session,再 SQL 和安全。

第一组

项目结构 + Session(6 题)

Q1单选

本项目把数据库操作单独放在 db.py 里,这种做法叫?

A面向对象
B分层架构(视图层/数据层分离)
C函数式编程
D反射
解析:每层只做一类事,互不干扰——这是写"能维护"项目的基本功。
Q2单选

Flask 中要使用 session,必须先做哪件事?

A装 Flask-Session
B建数据库表
C设置 app.secret_key
D开 debug 模式
解析:Flask 默认把 session 数据签名后写在 cookie 里,签名需要 SECRET_KEY。不设就报错。
Q3单选

@login_required 装饰器干了什么?

A把视图函数改成异步
B在执行视图前先看 session 有没有 username,没有就跳登录页
C验证密码
D记录访问日志
解析:装饰器在不动原函数的前提下"前后包一层"。把"先检查登录态"这种重复逻辑抽出来,避免每个视图都写一遍。
Q4单选

session.clear() 等价于:

A删数据库 session 表
B把当前用户 session 字典清空(=退出登录)
C关闭浏览器
D重启 Flask 进程
解析:session 在 Flask 里是个特殊字典,clear 把它清空,登录态就没了。
Q5判断

init_db.py 这种"建表 + 灌测试数据"的脚本应该跑很多次。

解析:它是"一次性"脚本——每次跑都会 DROP 重建,已有数据会全没。线上千万别误跑。
Q6判断

SQLite 每次写完操作都需要 conn.commit(),否则改动不会落到磁盘。

解析:这是新人常掉的坑——insert 跑完没提交,重启后数据没了。SELECT 不需要 commit。
第二组

SQL 与安全(6 题)

Q1单选

下面哪种写法有 SQL 注入风险

Aconn.execute(f"SELECT * FROM users WHERE name='{name}'")
Bconn.execute("SELECT * FROM users WHERE name=?", (name,))
C用 SQLAlchemy ORM
D用 Django ORM
解析:字符串拼接 SQL = 永远的禁忌。用占位符 ? 让数据库驱动帮你转义,绝对安全。
Q2单选

如果 init_db 里写 password TEXT NOT NULL,但上线项目对密码字段最不能省的处理是?

A把密码长度限制在 6-12 位
B用哈希算法(如 bcrypt / argon2)+ 盐存储,绝不存明文
C把密码字段加密成 base64
D限制只能字母数字
解析:明文存密码 = 数据库一泄漏所有用户密码全暴露。base64 不是加密只是编码。哈希 + 盐才是行业标准。
Q3单选

SQL ORDER BY created DESC 是什么意思?

A按 created 字段升序排
B只取 created 字段
C按 created 字段降序排(最新的在前)
D分组
解析:ASC = 升序(默认),DESC = 降序。文章列表都用 DESC,让最新的在最上面。
Q4单选

Jinja 模板里循环若集合为空想显示提示文字,应该用?

A{% if articles %}{% endif %} 包一层
BJS 判空
C视图层判空
D{% for ... %}{% else %}{% endfor %}
解析:Jinja 的 for...else 专门为这种场景设计——循环为空时走 else。比 if 包一层更优雅。A 也能实现但不是首选。
Q5单选

用户访问 / 没登录被拦,到底是哪行代码起作用?

Aapp.run(...)
B视图上面的 @login_required 装饰器
Cdb.list_articles()
Drender_template(...)
解析:装饰器在视图执行前检查 session——是它把没登录的用户重定向到登录页的。
Q6判断

本项目把账号密码写在数据库里(明文)只是课堂演示。实际项目里这是严重错误,密码必须哈希存储。

解析:明文存密码 = 数据库泄漏后所有人都被攻陷。生产代码必须用 bcrypt / argon2 + 盐。下节课给它升级。