简介:这是一份面向Flask初学者的轻量级Web开发练习项目,围绕一个简化博文系统串联用户登录登出、博文发布查看、SQLite3数据存储与Jinja2模板渲染等核心环节,适合已掌握Python基础、希望通过小案例理解Web应用完整流程的读者动手复现。压缩包共8个文件,约5KB,包含Python主程序与编译文件、SQL建表脚本、SQLite数据库、HTML模板页面及CSS样式文件,分别对应后端逻辑、数据表结构、页面展示与基础样式,体量虽小但结构相对完整。该资源已有3087人学习,可作为课程实验或自学练手参考。读者能从中梳理路由与视图函数、session身份验证、表单处理、模板继承及错误页面的组织方式,并借助建表脚本和数据库文件快速还原运行环境,理解从数据存储到页面渲染的基本链路。
1. 从一张 Excel 台账说起:为什么拿 Flask + sqlite3 敲一个试验楼小 Demo
管试验楼的那张 Excel 台账,我见过太多版本:A 同学改一版发到群里,B 同学在本地又改一版,最后哪一层的房间数据是真的,谁也说不清。这次干脆用 Python Flask 加标准库 sqlite3,敲一个能跑起来的试验楼小 Demo:房间登记、状态筛选、分页浏览、编辑删除,一个完整的 web 开发闭环走完,不依赖任何外部数据库服务,解压就能启动。它适合两类人:刚学完 Python 语法、想找一个能把请求—响应—存储串起来的练手项目的人;以及要交课程设计、需要一套结构说得清、代码读得懂的 Flask 实例的读者。下面从需求怎么收敛讲起,一直拆到怎么验收答辩。
2. 需求收敛与技术选型:四个数据动作与三个必须提前定下来的决定
动手写第一行代码之前,我一般会先逼自己回答两个问题:这张台账到底存什么、谁在用。试验楼这个场景看起来简单,真正落到表结构上却很容易随手加字段,加到后面自己都记不清哪一列是干嘛的。这一章先把需求压到最小可用集合,再把框架和存储选型的理由交代清楚,后面所有代码都建立在这两个决定上。
2.1 把「试验楼台账」压成两张表和四类操作
原始需求是一句很模糊的话:能查房间、能改状态。把它翻译成数据动作,其实只有四类:新增一间房、按条件查一批房、看一间房的详情并改它、把废弃的房间删掉。这四类动作对应两张表就够了。
第一张是主表room,存房间本身的静态属性和当前状态。字段我定成:房间编号code(唯一,人工录入,比如A-0301)、名称name、楼层floor、面积area、状态status(只允许idle/in_use/repair三值)、责任人owner、创建时间created_at、版本号version。第二张是record,记录每次状态变更,字段是房间 id、旧状态、新状态、备注、发生时间。第二张表不是为了好看,而是答辩时最容易被追问的点——「你怎么知道这间房上周是不是维修过」,有这张表就能答。
这里有个设计取舍要说明:房间编号用人工录入的唯一字符串,不用自增 id 当业务主键。原因是试验楼的房间编号本身就是有含义的(楼栋—楼层—序号),让业务人员用熟悉的方式检索,比让他记一个数据库自增数字靠谱得多。自增id只作为内部外键使用,不出现在页面上。代价是插入时要处理唯一约束冲突,这一点在第 4 章的登记接口里会专门讲。
四类操作再往下拆,就是页面清单:列表页(带关键词、状态筛选、分页)、登记页、详情页、编辑页。删除不单独做页面,放在列表行的操作列里,加一次二次确认。这个页面清单确定下来,路由表基本就定了,不会写到一半又回头加功能。
2.2 为什么是 Flask:三个候选框架的取舍
同一件事,Django、FastAPI、Flask 都能做。选 Flask 不是因为它「轻」,而是因为这个 Demo 的核心诉求是让读者能一眼看完全部代码路径。Django 的 admin 太强,一行模型定义就能生成整套增删改查,但正因为太强,交上去的东西里有一大半是框架代劳的,答辩时说不清「这个分页是你写的还是框架给的」。FastAPI 更适合纯接口场景,模板渲染和表单处理不是它的主场,而这个 Demo 需要服务端渲染的页面。
| 维度 | Flask | Django | FastAPI |
|---|---|---|---|
| 上手成本 | 低,二十行能跑起来 | 中,需要理解 app、settings、urls 的分层 | 中,需要理解依赖注入 |
| 自带 ORM | 无,直接用 sqlite3 或 SQLAlchemy | 有,功能完整 | 无,通常配 SQLAlchemy |
| 服务端模板 | Jinja2 内置 | 内置 | 需额外接入 |
| 适合本 Demo 的理由 | 代码全在明面上,SQL 可见 | 生成代码多,讲解成本高 | 偏接口,页面渲染绕 |
Flask 的代价也很明确:路由、校验、数据库连接、分页,全部要自己写。对练手项目来说这恰恰是好事,每个环节都有明确的「我写的这一行在干什么」。我在带人做课程设计时有个习惯判断:如果一个项目交上去,作者说不出某个功能是谁实现的,那这个功能就不该出现在项目里。
2.3 sqlite3 标准库的边界:够用在哪,不够用在哪
用标准库sqlite3而不是 SQLAlchemy,同样是出于「看得见」的考虑。一句db.execute("SELECT ... WHERE status = ?", (status,))摆在代码里,参数绑定、SQL 结构、返回的 Row 对象,全都能指着讲。ORM 会把这条 SQL 藏进 QuerySet 里,对已经会写 SQL 的人是效率,对还在学的人是黑匣子。
但 sqlite3 有硬边界,得提前说清楚,不然做到一半才发现就晚了。它采用库级写锁,同一时刻只允许一个写事务,读可以并发。这意味着:
- 十几个人的内网小工具、课程演示、单机部署,完全够用,读性能比想象中好。
- 一旦出现高频写入(比如每秒几十条状态上报),就会频繁遇到
database is locked。 - 它是单文件数据库,不适合多台机器同时挂载同一个文件(网络盘上跑 sqlite 是经典翻车场景)。
缓解手段有三个,我在 Demo 里都加上了:连接建立后立刻执行PRAGMA foreign_keys = ON打开外键约束(sqlite 默认是关的,这点很反直觉);写入路径统一走「短事务 + 立即提交」,不在事务里做网络或文件 IO;启动时设置PRAGMA journal_mode = WAL,让读写不互相阻塞。这三条做完,一个试验楼规模的应用不会碰到锁问题。如果哪天要扩到多机部署,迁移路径也清楚:SQL 语句基本能原样搬到 PostgreSQL,把?占位符换成%s即可。
3. 总体架构设计:应用工厂、蓝图与请求级连接的职责划分
小 Demo 最容易写成的样子是:一个app.py里塞三百行,路由、SQL、模板渲染混在一起,能跑,但改一个字段要全文搜索。这种结构交上去,答辩老师随便问一句「数据库连接什么时候关的」,就答不上来。所以第二步是把目录和职责先划出来。
3.1 目录结构与启动入口
目录按职责分层,不按文件类型乱堆。视图、数据库、模板、静态资源各归各位,instance目录专门放运行时产生的数据库文件和密钥,且不进版本库。
lab-demo/ ├── run.py # 开发环境启动入口 ├── schema.sql # 建表脚本 ├── requirements.txt ├── instance/ # 运行时生成,不进 git │ └── lab.sqlite3 └── app/ ├── __init__.py # 应用工厂 create_app ├── db.py # 连接获取与释放 ├── views/ │ ├── __init__.py │ └── record.py # 房间相关蓝图 ├── templates/ │ ├── base.html │ └── record/ │ ├── list.html │ ├── form.html │ └── detail.html └── static/ └── style.css启动入口只有三行,把「创建应用」和「跑起来」分开。这样测试的时候可以直接create_app({"TESTING": True, "DATABASE": ":memory:"})拿到一个干净的实例,不用改任何环境变量。
# run.py from app import create_app app = create_app() if __name__ == "__main__": # debug 只在本地开发开,部署时用 gunicorn 之类接管 app.run(host="127.0.0.1", port=5000, debug=True)应用工厂负责三件事:读配置、注册数据库钩子、注册蓝图。注意instance_relative_config=True这行,它让app.instance_path指向项目根下的instance/,而不是包内部,数据库文件就不会被打进代码目录。
# app/__init__.py import os from flask import Flask def create_app(test_config=None): app = Flask(__name__, instance_relative_config=True) app.config.from_mapping( SECRET_KEY=os.environ.get("SECRET_KEY", "dev-only-change-me"), DATABASE=os.path.join(app.instance_path, "lab.sqlite3"), PAGE_SIZE=10, # 列表页每页条数,改这一个值全站生效 ) if test_config: app.config.update(test_config) # instance 目录不存在时 sqlite 会报 unable to open database file os.makedirs(app.instance_path, exist_ok=True) from . import db db.init_app(app) from .views import record app.register_blueprint(record.bp) return app参数说明:SECRET_KEY用于 flash 消息的会话签名,代码里给了开发默认值,但部署时必须用环境变量覆盖;DATABASE走instance_path拼接,好处是换机器不用改代码;PAGE_SIZE抽成配置项,分页逻辑里不写死数字,这是个小细节,但改需求时省事。
3.2 用 g 对象和 teardown_appcontext 管住连接生命周期
数据库连接最常见的两种错误写法,一种是每次查询都sqlite3.connect()然后忘了关,文件句柄越积越多;另一种是全局建一个连接对象反复用,多线程下直接崩。Flask 给的正解是g:一次请求一个连接,请求结束时统一关掉。
# app/db.py import sqlite3 from flask import current_app, g def get_db(): """同一个请求内复用同一个连接,不存在则创建。""" if "db" not in g: g.db = sqlite3.connect( current_app.config["DATABASE"], detect_types=sqlite3.PARSE_DECLTYPES, ) # 让查询结果支持按列名访问,row["name"] 而不是 row[1] g.db.row_factory = sqlite3.Row # sqlite 默认不校验外键,必须每个连接单独打开 g.db.execute("PRAGMA foreign_keys = ON") # WAL 模式:读写不互相阻塞,小并发场景明显更稳 g.db.execute("PRAGMA journal_mode = WAL") return g.db def close_db(exc=None): """请求结束(无论成功还是异常)都会调用,保证连接被释放。""" db = g.pop("db", None) if db is not None: db.close() def init_app(app): app.teardown_appcontext(close_db)这段代码的关键点在teardown_appcontext,它注册的是「应用上下文销毁」钩子,不是「请求结束」钩子,所以即使视图函数抛异常,连接也会被关闭。g.pop("db", None)里的默认值None不能省,否则在没有创建连接的请求(比如纯静态资源)里会抛 KeyError,这种错误在日志里看起来非常莫名其妙。
row_factory = sqlite3.Row这一行的收益比看起来大。默认返回元组,模板里只能写row[1]、row[2],改一个字段顺序全站错位;设成 Row 之后可以row["name"],模板可读性直接上一个台阶,而且dict(row)能直接喂给 JSON 接口。
3.3 建表脚本、索引与初始化数据
建表脚本单独放一个.sql文件,用 Flask 的自定义命令执行,不写进 Python 字符串里。这样做的好处是:建表语句可以被 sqlite 命令行工具直接执行,排查问题时不用起应用。
-- schema.sql DROP TABLE IF EXISTS record; DROP TABLE IF EXISTS room; CREATE TABLE room ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, floor INTEGER NOT NULL, area REAL NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'idle' CHECK (status IN ('idle', 'in_use', 'repair')), owner TEXT, version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); -- 列表页最常见的筛选维度,先建索引 CREATE INDEX idx_room_status ON room (status); CREATE INDEX idx_room_floor ON room (floor, code); CREATE TABLE record ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL REFERENCES room (id) ON DELETE CASCADE, old_status TEXT, new_status TEXT NOT NULL, note TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE INDEX idx_record_room ON record (room_id, created_at DESC);几个要点值得展开。CHECK约束把状态限定成三个值,等于在数据库层做了一次兜底校验,即使将来有别的入口写数据,也塞不进脏值。ON DELETE CASCADE让删除房间时自动清掉它的变更记录,避免留下孤儿数据——注意这依赖PRAGMA foreign_keys = ON,忘了开这条,级联删除是静默失效的,属于典型的「以为写了其实没生效」。
把建表命令挂到 Flask CLI 上,执行一次flask --app run init-db就能重建库:
# app/db.py 追加 import click from flask.cli import with_appcontext @click.command("init-db") @with_appcontext def init_db_command(): """重建表结构并写入两条演示数据。""" db = get_db() with current_app.open_resource("schema.sql") as f: db.executescript(f.read().decode("utf-8")) db.executemany( "INSERT INTO room (code, name, floor, area, status, owner) VALUES (?,?,?,?,?,?)", [ ("A-0301", "机电试验间", 3, 48.5, "in_use", "某工程师"), ("A-0302", "材料留样间", 3, 22.0, "idle", None), ], ) db.commit() click.echo("数据库已重建")open_resource读的是包内资源,路径相对于app包,所以schema.sql放在包根目录下才能被找到。executescript会隐式提交之前挂起的事务,如果前面已经有未提交的写操作,会被一起提交掉,所以这个命令里我把它放在最前面、独立使用。
4. 模块实现:登记、分页查询、编辑删除的完整代码路径
架构搭完,接下来是真正会被逐行检查的部分。这一章的四个小节对应四条完整链路,每条都给出可直接运行的代码和改动点说明。写得细一点,是因为读者拿这份 Demo 改造成自己的课题时,改动基本都发生在这几个函数里。
4.1 登记表单:前端约束与后端校验双保险
表单校验只做前端等于没做。浏览器的required、type=number能被随手绕过,服务端必须有一份独立的校验逻辑。我把校验抽成一个纯函数,输入是request.form,输出是清洗后的字典和错误字典,视图函数只负责组织响应。
# app/views/record.py import sqlite3 from flask import Blueprint, flash, redirect, render_template, request, url_for from app.db import get_db bp = Blueprint("record", __name__, url_prefix="/rooms") STATUS_SET = {"idle", "in_use", "repair"} def parse_room_form(form): """把表单原始数据清洗成入库值,同时收集字段级错误。""" data, errors = {}, {} code = (form.get("code") or "").strip().upper() if not code or len(code) > 20: errors["code"] = "房间编号必填且不超过 20 个字符" data["code"] = code name = (form.get("name") or "").strip() if not name: errors["name"] = "房间名称必填" data["name"] = name try: floor = int(form.get("floor", "")) if not 1 <= floor <= 99: raise ValueError data["floor"] = floor except (TypeError, ValueError): errors["floor"] = "楼层必须是 1 到 99 之间的整数" try: area = round(float(form.get("area", 0)), 1) if area <= 0: raise ValueError data["area"] = area except (TypeError, ValueError): errors["area"] = "面积必须是大于 0 的数字" status = form.get("status", "idle") if status not in STATUS_SET: errors["status"] = "状态取值非法" data["status"] = status data["owner"] = (form.get("owner") or "").strip() or None return data, errors这段代码有三个容易被忽略的处理。第一,编号统一转大写,避免a-0301和A-0301被当成两条不同记录,这种问题在人工录入场景下几乎必然发生。第二,float转换包在try里,因为用户可能在数字框里粘贴空格或全角字符,直接float()会抛ValueError把整个请求打成 500。第三,责任人字段空字符串转None,数据库里就统一是 NULL,而不是一半空串一半 NULL,将来做「未分配房间」统计时不用写两种条件。
视图函数负责处理唯一约束冲突。这里的关键是判断异常类型,而不是笼统except Exception:
@bp.route("/new", methods=("GET", "POST")) def room_create(): if request.method == "POST": data, errors = parse_room_form(request.form) if not errors: db = get_db() try: db.execute( "INSERT INTO room (code, name, floor, area, status, owner)" " VALUES (?, ?, ?, ?, ?, ?)", (data["code"], data["name"], data["floor"], data["area"], data["status"], data["owner"]), ) db.commit() except sqlite3.IntegrityError: # 唯一索引冲突:编号已存在 errors["code"] = f"房间编号 {data['code']} 已存在" else: flash("登记成功") # PRG 模式:提交后重定向,防止刷新导致重复插入 return redirect(url_for("record.room_list")) for field, msg in errors.items(): flash(f"{field}: {msg}") return render_template("record/form.html", room=None, statuses=sorted(STATUS_SET))注意else分支的位置:只有当try块没有抛异常时才执行redirect。如果写成db.commit()之后直接redirect,唯一约束冲突时也会跳转,用户会以为保存成功了。另外except sqlite3.IntegrityError只能捕获这一种,NOT NULL违反也是同一个异常类型,所以错误文案要写得泛一点,或者按错误消息细分——Demo 阶段用前者就够。
4.2 列表页:条件拼装、排序与分页的 SQL 写法
列表页是查询逻辑最密集的地方,也是最容易埋 SQL 注入的地方。原则只有一条:SQL 结构可以拼,参数值必须走占位符。下面这段是完整实现。
@bp.route("/") def room_list(): page = request.args.get("page", 1, type=int) keyword = (request.args.get("q") or "").strip() status = request.args.get("status") or "" where, params = ["1 = 1"], [] if keyword: # 结构片段是常量,用户输入全部走 ? 绑定 where.append("(code LIKE ? OR name LIKE ? OR IFNULL(owner, '') LIKE ?)") like = f"%{keyword}%" params.extend([like, like, like]) if status in STATUS_SET: where.append("status = ?") params.append(status) sql_where = " AND ".join(where) db = get_db() total = db.execute( f"SELECT COUNT(*) FROM room WHERE {sql_where}", params ).fetchone()[0] per_page = current_app.config["PAGE_SIZE"] pages = max(1, (total + per_page - 1) // per_page) # 向上取整 page = min(max(page, 1), pages) # 越界页码钳回边界 rows = db.execute( f"SELECT * FROM room WHERE {sql_where}" " ORDER BY floor ASC, code ASC LIMIT ? OFFSET ?", params + [per_page, (page - 1) * per_page], ).fetchall() return render_template( "record/list.html", rows=rows, total=total, page=page, pages=pages, keyword=keyword, status=status, statuses=sorted(STATUS_SET), )参数说明与设计意图:request.args.get("page", 1, type=int)里的type=int是 Flask 提供的类型转换,传page=abc会安静地退回默认值而不是报错,比手写int()加try省事。页码越界处理min(max(page, 1), pages)看着不起眼,但用户把 URL 里的page=改成 999 时,如果不钳制就会返回空列表,看起来像数据丢了,这是最常见的「假故障」。f"%{keyword}%"里的%是 LIKE 的通配符,如果用户搜索词本身带%或_,会变成模糊匹配,Demo 阶段可以接受,正式项目里应该做转义并在 SQL 后加ESCAPE '\'。
模板端用 Jinja2 的循环和条件渲染,分页控件保留当前的筛选参数,否则翻页会丢掉查询条件——这个小 bug 几乎每个手写分页的人都会犯一次。
<!-- app/templates/record/list.html 片段 --> {% for row in rows %} <tr> <td>{{ row["code"] }}</td> <td>{{ row["name"] }}</td> <td>{{ row["floor"] }}</td> <td>{{ "%.1f"|format(row["area"]) }}</td> <td>{{ row["status"] }}</td> <td>{{ row["owner"] or "-" }}</td> </tr> {% else %} <tr><td colspan="6">没有符合条件的房间</td></tr> {% endfor %}分页链接里必须带上q和status,写法是url_for("record.room_list", page=page-1, q=keyword, status=status)。Jinja2 的for ... else语句在循环零次时执行else分支,用来做空状态提示比外面套一层if干净。
4.3 编辑与删除:版本号乐观锁和字段白名单
编辑有两个坑:并发覆盖和批量赋值。并发覆盖指的是两个人同时打开编辑页,后提交的把先提交的覆盖掉,且没有任何提示。批量赋值的风险更大——如果代码里写for k, v in form.items(): update 语句拼接 k,攻击者往表单里塞一个version=999就能绕过任何检查。
我的做法是字段白名单加版本号乐观锁,两件事在一条 SQL 里完成:
@bp.route("/<int:room_id>/edit", methods=("GET", "POST")) def room_edit(room_id): db = get_db() room = db.execute("SELECT * FROM room WHERE id = ?", (room_id,)).fetchone() if room is None: abort(404) if request.method == "POST": data, errors = parse_room_form(request.form) version = request.form.get("version", type=int) if version is None: errors["version"] = "缺少版本号,请刷新页面后重试" if not errors: cur = db.execute( "UPDATE room SET name = ?, floor = ?, area = ?, status = ?," " owner = ?, version = version + 1" " WHERE id = ? AND version = ?", (data["name"], data["floor"], data["area"], data["status"], data["owner"], room_id, version), ) db.commit() if cur.rowcount == 0: # 版本号对不上:别人已经改过这条记录 flash("该记录已被他人修改,请刷新后重新提交") else: flash("保存成功") return redirect(url_for("record.room_detail", room_id=room_id)) return render_template("record/form.html", room=room, statuses=sorted(STATUS_SET))这里的重点是WHERE id = ? AND version = ?和version = version + 1。更新时版本号自增,若两个请求带着同一个旧版本号进来,第二条的rowcount会是 0,程序据此提示冲突而不是静默覆盖。这比自己写一把锁简单得多,也不需要额外字段。注意房间编号code没有出现在 SET 里——编号是业务主键,一旦允许修改,历史记录里所有引用都会对不上,我在设计上直接禁止改编号,只能删了重建。
删除要处理外键级联。因为record表上建了ON DELETE CASCADE,删room会自动清掉关联记录,所以一条 DELETE 就够,但要给用户留一次反悔机会:
@bp.route("/<int:room_id>/delete", methods=("POST",)) def room_delete(room_id): db = get_db() # 只接受 POST,GET 删除会被浏览器预取或爬虫误触发 db.execute("DELETE FROM room WHERE id = ?", (room_id,)) db.commit() flash("已删除") return redirect(url_for("record.room_list"))删除接口只允许 POST,这一点没有商量空间。用 GET 做删除,浏览器预加载、链接分享、爬虫抓取任何一种行为都会把数据删掉,而且日志里看不出是谁干的。
4.4 模板继承与静态资源组织
模板用一层base.html管住骨架,子模板只填内容块。导航、flash 消息、样式引用集中在一处,改版式不用改四个文件。
<!-- app/templates/base.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{% block title %}试验楼台账{% endblock %}</title> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> </head> <body> <nav><a href="{{ url_for('record.room_list') }}">房间列表</a></nav> {% for msg in get_flashed_messages() %} <div class="flash">{{ msg }}</div> {% endfor %} <main>{% block content %}{% endblock %}</main> </body> </html>url_for('static', filename=...)会自动带上静态资源前缀,将来把应用挂到子路径下(比如/lab/)不用改任何模板。get_flashed_messages()配合视图里的flash(),把「保存成功」「编号重复」这类一次性提示串起来,页面刷新一次就消失,不会残留。
5. 踩坑与排查:这个 Demo 最容易翻车的五个点
这一章列的都是我在实际敲这个 Demo 时真切遇到过的报错,不是从文档里抄的。每个都按「看到什么现象 → 为什么 → 怎么改」写,方便对号入座。
5.1 报错unable to open database file
现象:本地跑得好好的,换台机器或打个包解压后启动,第一个查询就抛这个异常,栈里指向sqlite3.connect。
原因:app.instance_path对应的目录不存在。sqlite 连库时会创建文件,但不会创建父目录,目录缺失就直接报这个错。项目根目录下的instance/通常被写进.gitignore,所以从仓库拉下来的人一定没有这个目录。
解决:在create_app里os.makedirs(app.instance_path, exist_ok=True),且必须放在任何get_db调用之前。exist_ok=True不能省,否则第二次启动会因为目录已存在抛FileExistsError。
5.2 中文正常,但时间总是差 8 小时
现象:页面显示的创建时间比实际时间早 8 小时,但数据库里看又是对的。
原因:sqlite 的datetime('now')返回的是 UTC。我在建表默认值里写的是datetime('now', 'localtime'),看起来已经处理了,但如果某条 INSERT 语句显式传了 Python 算出来的时间,或者用了别的表的默认值,就会混进 UTC 时间。两套时间混在一起,比统一错还难查。
解决:全站统一一种时间口径。我的做法是只信任数据库默认值,任何 INSERT 都不显式传created_at,需要展示时在 SQL 里用datetime(created_at, 'localtime')转换。如果业务跨时区,就不要用 localtime,前端做时区转换更稳。
5.3 提交表单后按 F5 提示「确认重新提交表单」
现象:登记成功后刷新页面,浏览器弹窗问要不要重新提交,点了确定就多出一条重复记录。
原因:POST 处理完直接render_template返回页面,浏览器地址栏还停在 POST 上,刷新就是重放请求。这是最经典的翻车方式,测试时手快一点就能造出两条完全一样的房间记录。
解决:PRG 模式(Post/Redirect/Get),提交成功后统一redirect到列表页或详情页。代码里我特意把redirect放在else分支,就是为了保证只有真正写入成功才跳转。
5.4 改了 CSS 但浏览器里没变化
现象:改完style.css,刷新页面样式还是旧的,强刷才好。
原因:Flask 默认给静态文件发Cache-Control: max-age,浏览器在有效期内直接用本地缓存,不发请求。开发阶段这个缓存纯粹添乱。
解决:给静态文件 URL 加版本参数,或者开发时把app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0。更省事的做法是在模板里写url_for('static', filename='style.css', v=1),每次改样式把v加一,生产环境也不用担心缓存问题。
5.5database is locked在本地从不出现,一部署就冒出来
现象:本机单点测试完全正常,放到内网让几个人同时用,偶尔报这个错,日志里看不出明显规律。
原因:sqlite 同一时刻只允许一个写事务。有几个常见触发点:某个视图开了事务但某条路径忘了 commit,连接被 teardown 关掉时事务还在;或者把连接存到了模块级全局变量,被多个线程共用;或者用网络盘挂载数据库文件,文件锁本身就不可靠。
解决:三步走。第一,确认每个写操作后面都有commit(),最好用with db:上下文管理器包住写操作,异常时自动回滚。第二,连接必须走g,不要用全局变量。第三,启动时设PRAGMA journal_mode = WAL并给sqlite3.connect加timeout=5,让写入稍微排一下队而不是立刻报错。做完这三步还不行,说明确实到了该换数据库的规模了。
6. 验收与答辩:用一份可复现的检查单证明它真的能跑
到这个阶段,代码基本写完了,剩下的问题是怎么让人相信它真的能跑。我的经验是:答辩现场不要从「我用了什么技术」讲起,先让对方看到一条完整的数据流走通,再回头说实现。演示顺序建议是:空列表 → 登记一间房 → 列表出现这条记录 → 搜索关键词命中 → 编辑改状态 → 详情页看到变更记录 → 删除 → 列表恢复空。这条链路走完,功能覆盖已经说明了七成,剩下三成靠检查单和代码讲解。
下面这份验收清单是我每次交付前必走一遍的,可以直接抄:
| 检查项 | 操作方式 | 通过标准 |
|---|---|---|
| 冷启动 | 删掉instance/后执行init-db再启动 | 无报错,演示数据可查 |
| 唯一约束 | 连续两次提交同一房间编号 | 第二次提示编号已存在,库中仅一条 |
| 边界值 | 楼层填 0、面积填 -1、面积填abc | 均被拦截,页面不出现 500 |
| 注入尝试 | 关键词输入' OR 1=1 -- | 返回空结果,不报错,不泄露数据 |
| 并发编辑 | 两个标签页同时打开同一房间并先后保存 | 后保存者收到「已被他人修改」提示 |
| 重复提交 | 登记成功后按 F5 | 无重复记录,跳转正常 |
| 分页越界 | URL 手动改page=999 | 回到最后一页,不出现空白页 |
| 级联删除 | 删除有变更记录的房间 | 记录表对应行同步消失,无孤儿数据 |
答辩被问到的概率最高的三个问题,我一般会提前准备答案。第一,「为什么不用 ORM」——因为这份 Demo 的重点是展示 SQL 与请求生命周期的对应关系,用 ORM 会把这条线索藏起来,同时我会说明真实项目里我会用 SQLAlchemy。第二,「数据量大了怎么办」——索引已经建在status和floor, code上,超过十万行会把 LIKE 换成 FTS5 全文索引,再往上就迁 PostgreSQL,SQL 基本能平移。第三,「怎么保证安全」——参数全部走占位符绑定,删除只允许 POST,模板输出依赖 Jinja2 默认转义,同时坦白说明这个 Demo 没有做登录鉴权,是已知边界而不是疏漏。
有次帮人改这份 Demo,他把所有 SQL 都写成 f-string 拼接,本地测得好好的,演示前一天我用' OR '1'='1试了一下,整个房间列表全出来了。从那以后我每次交东西前,都会拿这条字符串在每一个输入框里过一遍,包括那些「反正只有自己人用」的字段。希望这套结构和这份检查单,能帮你少走一遍我踩过的路。
本文还有配套的精品资源,点击获取