简介:围绕 Python Flask 构建简易博文系统的试验型 Demo 源码包,面向刚接触 Web 开发、希望把路由、模板与数据库串起来练习的初学者,可用于理解登录登出、博文发布查看等基础流程。压缩包共 8 个文件,约 5KB,以 html 模板、py 主程序、sql 建表脚本、db 数据库和 css 样式为主,另含缓存文件,体量轻巧,便于快速查阅与本地试验。已有 3087 人学习。包内围绕 Flask 路由与视图函数、Jinja2 页面渲染、SQLite3 用户与博文表结构、session 身份验证等环节组织,能帮助读者对照一个可运行的小系统,梳理请求处理、模板变量、表单提交和数据持久化的对应关系。对正在完成课程设计或小型练手项目的人而言,这些文件覆盖了从数据库初始化到页面呈现的关键环节,方便按模块拆解并验证自己的实现思路,也可作为二次改造的起点,在有限代码中把握 Web 应用从入口到展示的基本骨架。
1. 试验楼借用登记这个需求,为什么先用 Flask + sqlite3 跑通
试验楼里房间和设备借用还靠共享表格登记,冲突、漏记、归还找不到人,几乎每周都要人工对一遍。这个 Python Flask 开发实例就从一个能跑通的试验楼小 Demo 开始:登录、房间与设备台账、借用申请、审批、归还、统计看板,先形成一条最小闭环。技术栈选 Flask + sqlite3,原因很直接:Python web 开发入门成本低,sqlite3 不用单独装服务,建表脚本一跑就能演示;Flask 的路由、模板、session 足够把业务状态讲清楚。这套结构适合正在做课程设计、毕设前期原型的人,也适合想拿一个能改字段就上线内部登记页的从业者。Demo 不求大,求每条状态流转都能验收。
2. 总体架构设计:应用工厂、Blueprint 与四张表的职责边界
这个 Demo 的功能边界要先收住,否则很容易写成“什么都能管”的后台。试验楼场景真正跑起来的最小集合是:用户登录、房间台账、设备台账、借用单申请与审批、归还、统计看板。围绕这六件事,代码按应用工厂 + Blueprint + 原生 sqlite3 组织,每一层只做一件事。应用工厂负责配置和注册蓝图,Blueprint 按业务拆文件,sqlite3 连接挂在请求上下文里,模板只负责展示。这样后面加“设备维修”或“黑名单”时,不用把路由全塞进一个 app.py。
2.1 目录结构与启动链路
先把目录定下来,后面写代码才不会一边写一边搬文件。下面这棵目录树是这套 Demo 的骨架,instance 目录运行时生成,不放进版本库;schema.sql 放根目录,初始化命令直接读它。
lab-booking/ ├── app.py # 启动入口:create_app() ├── schema.sql # 建表脚本 ├── requirements.txt ├── instance/ # sqlite3 文件与运行时数据,不提交 ├── app/ │ ├── __init__.py # 应用工厂,注册蓝图和 CLI │ ├── db.py # 连接、关闭、初始化 │ ├── auth.py # 登录、会话、权限装饰器 │ ├── rooms.py # 房间与设备台账 │ ├── loans.py # 借用单申请、审批、归还 │ ├── dashboard.py # 统计看板 │ ├── templates/ │ └── static/ └── tests/ └── test_loans.py # 用临时库跑状态流转启动链路是:app.py 调 create_app(),工厂里读配置、建 instance 目录、注册四个 Blueprint,再注册 init-db 和 mark-overdue 两个 CLI 命令。url_prefix 把房间、借用、看板分开,模板里 url_for 写蓝图名,后面改前缀不会牵连一堆硬编码链接。这个结构看起来比单文件麻烦,但答辩时被问到“业务怎么分层”,目录本身就能回答一半。
2.2 数据模型:用户、房间、设备、借用单四张表怎么落
四张表的关系不复杂:user 存账号和角色,room 存房间,device 挂在 room 下,loan 同时引用 user、room、device。时间字段统一用 TEXT 存 ISO8601 字符串,sqlite3 的文本比较在这种格式下是可靠的;状态字段用 CHECK 约束把取值锁死,避免后面出现“已借出”和“借出中”两套写法。
PRAGMA foreign_keys = ON; CREATE TABLE user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('student', 'admin')), created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE room ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, capacity INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')) ); CREATE TABLE device ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL REFERENCES room(id) ON DELETE CASCADE, name TEXT NOT NULL, asset_no TEXT UNIQUE NOT NULL, status TEXT NOT NULL DEFAULT 'idle' CHECK (status IN ('idle', 'borrowed', 'repair')) ); CREATE TABLE loan ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES user(id), room_id INTEGER NOT NULL REFERENCES room(id), device_id INTEGER REFERENCES device(id), start_at TEXT NOT NULL, end_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'borrowed', 'returned', 'overdue')), note TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE INDEX idx_loan_time ON loan(room_id, start_at, end_at);user.role 只放 student 和 admin 两种值,loan 的 device_id 允许为空,表示只借房间不借设备。room.status 关闭后不再出现在申请下拉框里,但历史单据还留着。loan 上建 (room_id, start_at, end_at) 联合索引,是因为冲突检测每次都要按房间和时间段筛。PRAGMA foreign_keys = ON要在每个连接上执行,sqlite3 默认不打开外键约束,这一点很容易翻车。
2.3 状态机与权限边界:谁能改哪张单
借用单是整套 Demo 的核心,状态不能靠模板按钮控制,必须写进 SQL 条件里。初始状态是 pending,管理员审批后进入 approved 或 rejected;管理员确认借出后变 borrowed;学生或管理员发起归还后变 returned;超过 end_at 仍为 borrowed 的单据由 CLI 扫描成 overdue。rejected 和 returned 是终态,不再参与冲突检测。
| 当前状态 | 动作 | 下一状态 | 操作者 |
|---|---|---|---|
| pending | 通过 | approved | admin |
| pending | 驳回 | rejected | admin |
| approved | 确认借出 | borrowed | admin |
| borrowed | 归还 | returned | 本人或 admin |
| overdue | 归还 | returned | 本人或 admin |
| borrowed | 超过结束时间 | overdue | 定时 CLI |
权限判断放在后端装饰器里:login_required 拦截未登录,admin_required 拦截非管理员。模板里用g.user['role'] == 'admin'隐藏审批按钮只是体验优化,不能当安全边界。状态更新一律带WHERE id=? AND status=?,执行完看rowcount,为 0 就提示“单据状态已变化”,这样两个人同时点审批时不会互相覆盖。
2.4 技术选型对比:为什么不上 ORM 和前端框架
这个 Demo 的讲解重点是请求进入后怎么校验、怎么查冲突、怎么改状态,不是模型迁移。Flask + 原生 sqlite3 的参数化查询足够把这条链路写清楚,代码量也压得住。ORM 不是不能用,而是前期会把时间花在模型关系和迁移配置上,答辩时反而讲不清业务规则落在哪。
| 方案 | 上手成本 | 适合场景 | 这个 Demo 的选择 |
|---|---|---|---|
| Flask + sqlite3 原生 | 低 | 课设、原型、内部小工具 | 选它,SQL 和状态机一目了然 |
| Django + ORM | 中 | 完整后台、需要权限体系 | 功能多但骨架重 |
| Flask + SQLAlchemy | 中 | 需要模型迁移和复杂查询 | 后期换,路由层不用大改 |
| 前端框架 + API | 高 | 交互复杂、多人协作 | 不引入,Jinja2 够用 |
不引入前端框架还有一个现实原因:试验楼登记页面的交互就是列表、表单、审批按钮,Jinja2 服务端渲染加少量 CSS 就能演示。后面真要拆 API,把 loans.py 里的函数抽成 service,再套一层 JSON 路由即可,前期不必为“可能扩展”付代价。
3. 核心业务实现:从登录鉴权到借用单流转的 Flask 路由
这一章落到代码。顺序按请求生命周期走:先建应用工厂和数据库连接,再处理登录会话,然后写借用申请、审批、归还和逾期扫描。每段代码都只保留关键逻辑,参数和失败分支在代码后说明。写这类 Demo 最容易犯的错是“页面能点通就算完”,但状态机的收口、并发修改的 rowcount、时间格式的统一,才是答辩时能讲出深度的部分。
3.1 应用工厂与数据库连接:g 对象和 teardown
sqlite3 的连接不能全局共享,Flask 的 g 对象正好按请求存放。get_db() 第一次调用时建连接,设置 row_factory 让查询结果能按列名取值,打开外键约束,并给一个 timeout,避免一遇到锁就立刻报错。teardown_appcontext 在请求结束时关闭连接,防止连接泄漏。
# app/db.py import sqlite3 import click 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, timeout=10, # 等锁最多 10 秒,避免一冲突就报错 ) g.db.row_factory = sqlite3.Row g.db.execute("PRAGMA foreign_keys = ON") return g.db def close_db(e=None): db = g.pop("db", None) if db is not None: db.close() def init_db(): db = get_db() with current_app.open_resource("schema.sql") as f: db.executescript(f.read().decode("utf8")) @click.command("init-db") def init_db_command(): init_db() click.echo("数据库已初始化") def init_app(app): app.teardown_appcontext(close_db) app.cli.add_command(init_db_command)timeout=10的单位是秒,sqlite3 在遇到写锁时会等这么久再抛异常;detect_types=sqlite3.PARSE_DECLTYPES让声明为 TIMESTAMP 的列自动转换,但这里的表用 TEXT 存时间,主要靠字符串比较,不依赖它。init-db命令只负责建表,不负责塞演示数据;演示数据可以另写 seed 命令,避免初始化时把测试账号带进正式库。应用工厂里把 DATABASE 指到 instance 目录,测试时用临时路径覆盖,同一个 create_app 就能跑两套环境。
3.2 登录与会话:session + 密码哈希的最小实现
登录不做复杂权限树,session 里只存 user_id,每次请求用 before_app_request 查出用户对象挂到 g.user。这样角色改了之后下一次请求就生效,不会出现“旧会话还带着管理员身份”的问题。密码用 werkzeug.security 的 generate_password_hash 和 check_password_hash,不存明文。
# app/auth.py import functools from flask import Blueprint, flash, g, redirect, render_template, request, session, url_for from werkzeug.security import check_password_hash from .db import get_db bp = Blueprint("auth", __name__) @bp.before_app_request def load_logged_in_user(): user_id = session.get("user_id") g.user = None if user_id is not None: g.user = get_db().execute( "SELECT id, username, role FROM user WHERE id=?", (user_id,) ).fetchone() @bp.route("/login", methods=("GET", "POST")) def login(): if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "") user = get_db().execute( "SELECT * FROM user WHERE username=?", (username,) ).fetchone() if user is None or not check_password_hash(user["password_hash"], password): flash("用户名或密码不正确", "error") else: session.clear() session["user_id"] = user["id"] return redirect(url_for("dashboard.index")) return render_template("auth/login.html") def login_required(view): @functools.wraps(view) def wrapped_view(**kwargs): if g.user is None: return redirect(url_for("auth.login")) return view(**kwargs) return wrapped_view def admin_required(view): @functools.wraps(view) def wrapped_view(**kwargs): if g.user is None or g.user["role"] != "admin": flash("需要管理员权限", "error") return redirect(url_for("dashboard.index")) return view(**kwargs) return wrapped_view登录成功后先session.clear()再写 user_id,是为了防止会话固定攻击;虽然 Demo 不展开安全专题,但这个动作成本低。load_logged_in_user用 before_app_request,所有蓝图都能拿到 g.user。装饰器只做拦截,真正的角色判断在路由和 SQL 条件里再兜一层。注册路由可以保留但限制为管理员创建账号,避免演示时谁都能注册管理员。
3.3 借用申请与审批:事务、冲突检测和状态更新
申请页要处理三件事:表单校验、时间段冲突、插入单据。校验放在独立函数里,方便测试直接调用;冲突检测用半开区间start_at < 新结束 AND end_at > 新开始,能识别部分重叠;插入后统一 commit。审批路由只允许 pending 被改成 approved 或 rejected,用 rowcount 判断是否有人抢先改过。
# app/loans.py(节选) from datetime import datetime from flask import Blueprint, abort, flash, g, redirect, render_template, request, url_for from .auth import admin_required, login_required from .db import get_db bp = Blueprint("loans", __name__) def validate_loan_form(room_id, start_at, end_at): if not room_id: return "请选择房间" try: start = datetime.fromisoformat(start_at) end = datetime.fromisoformat(end_at) except ValueError: return "时间格式应为 YYYY-MM-DDTHH:MM" if start >= end: return "结束时间必须晚于开始时间" if start < datetime.now(): return "开始时间不能早于当前时间" if (end - start).total_seconds() > 8 * 3600: return "单次借用不能超过 8 小时" return None @bp.route("/apply", methods=("GET", "POST")) @login_required def apply(): db = get_db() if request.method == "POST": room_id = request.form.get("room_id", type=int) start_at = request.form.get("start_at", "").strip() end_at = request.form.get("end_at", "").strip() note = request.form.get("note", "").strip() error = validate_loan_form(room_id, start_at, end_at) if error is None: conflict = db.execute( """SELECT COUNT(*) AS n FROM loan WHERE room_id=? AND status IN ('approved','borrowed','overdue') AND start_at < ? AND end_at > ?""", (room_id, end_at, start_at), ).fetchone()["n"] if conflict: error = "该时间段已被占用,换一个时间或房间" if error is None: db.execute( """INSERT INTO loan (user_id, room_id, start_at, end_at, note) VALUES (?, ?, ?, ?, ?)""", (g.user["id"], room_id, start_at, end_at, note), ) db.commit() flash("借用申请已提交,等待审批", "ok") return redirect(url_for("loans.my_loans")) flash(error, "error") rooms = db.execute("SELECT * FROM room WHERE status='open' ORDER BY name").fetchall() return render_template("loans/apply.html", rooms=rooms) @bp.route("/<int:loan_id>/review", methods=("POST",)) @admin_required def review(loan_id): action = request.form.get("action") if action not in ("approve", "reject"): abort(400) new_status = "approved" if action == "approve" else "rejected" db = get_db() cur = db.execute( "UPDATE loan SET status=? WHERE id=? AND status='pending'", (new_status, loan_id), ) db.commit() if cur.rowcount == 0: flash("单据状态已变化,刷新后重试", "error") else: flash("审批完成", "ok") return redirect(url_for("loans.all_loans"))validate_loan_form的四个参数类型要固定:room_id 是 int,start_at 和 end_at 是字符串,返回 None 表示通过。datetime.fromisoformat能直接吃YYYY-MM-DDTHH:MM,和<input type="datetime-local">的提交格式一致。冲突查询只把 approved、borrowed、overdue 算作占用,pending 不占时间,否则一个人提交未审批的单据就能把房间锁死。审批 UPDATE 带status='pending',rowcount 为 0 说明单据已被别人处理,这时候不要强行覆盖,提示刷新更稳。
注意:冲突检测和 INSERT 之间没有数据库级唯一约束,高并发下仍可能插进两条重叠单。课程设计演示够用;真要上生产,给时间段加排他约束或把检测和插入放进同一个
BEGIN IMMEDIATE事务。
3.4 归还与逾期:定时扫描与统计 SQL
归还动作要校验“是不是本人的单”以及“当前状态能不能还”。管理员可以代还,普通用户只能还自己的。状态只允许从 borrowed 或 overdue 改成 returned,其他状态一律拒绝。逾期不放在请求里扫全表,单独做一个 CLI 命令,演示时手动跑一次,生产环境交给定时任务。
# app/loans.py(续) @bp.route("/<int:loan_id>/return", methods=("POST",)) @login_required def return_loan(loan_id): db = get_db() loan = db.execute("SELECT * FROM loan WHERE id=?", (loan_id,)).fetchone() if loan is None: abort(404) if g.user["role"] != "admin" and loan["user_id"] != g.user["id"]: abort(403) cur = db.execute( "UPDATE loan SET status='returned' WHERE id=? AND status IN ('borrowed','overdue')", (loan_id,), ) db.commit() if cur.rowcount: flash("归还成功", "ok") else: flash("当前状态不能归还", "error") return redirect(url_for("loans.my_loans")) # 逾期扫描命令,注册到 app.cli import click @click.command("mark-overdue") def mark_overdue_command(): db = get_db() cur = db.execute( "UPDATE loan SET status='overdue' " "WHERE status='borrowed' AND end_at < datetime('now','localtime')" ) db.commit() click.echo(f"标记逾期 {cur.rowcount} 条")归还路由先查单据再判断权限,避免普通用户通过猜 id 操作别人的单。status IN ('borrowed','overdue')把可归还状态收口,approved 还没借出的单不能直接还。逾期命令用datetime('now','localtime')和建表时的默认时间保持同一时区口径;如果服务器时区不一致,end_at 和 now 的比较就会偏,后面避坑章会展开。CLI 命令适合答辩现场演示“系统能自动识别逾期”,也比在列表页每次请求扫全表更干净。
4. 页面与交互落地:Jinja2 模板、表单校验和统计看板
后端路由跑通之后,页面要让人能点、能看懂、能演示。这套 Demo 不引入前端构建链,模板按 base、列表、表单三层拆,CSS 只写必要布局。表单校验以服务端为准,前端 required 和 maxlength 只做体验。看板用一条聚合 SQL 把房间借用情况压成一页,答辩时翻到这一页,比列一堆代码更有说服力。
4.1 模板分层:base、列表页、表单页
base.html 管导航、flash 消息和内容块,所有页面继承它。导航里的“审批台”只对管理员显示,但后端 review 路由仍然有 admin_required,模板隐藏只是不让普通用户看到入口。flash 消息按 category 输出 ok 或 error,CSS 给两种颜色即可。
<!-- app/templates/base.html --> <!doctype html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>{% block title %}试验楼借用登记{% endblock %}</title> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> </head> <body> <nav> <a href="{{ url_for('dashboard.index') }}">看板</a> <a href="{{ url_for('rooms.list_rooms') }}">房间</a> <a href="{{ url_for('loans.my_loans') }}">我的借用</a> {% if g.user and g.user['role'] == 'admin' %} <a href="{{ url_for('loans.all_loans') }}">审批台</a> {% endif %} </nav> {% for category, message in get_flashed_messages(with_categories=true) %} <div class="flash {{ category }}">{{ message }}</div> {% endfor %} {% block content %}{% endblock %} </body> </html>{% block title %}和{% block content %}让子模板只写差异部分;url_for('static', filename='style.css')走 Flask 静态路由,换部署前缀不用改路径。导航里的 g.user 是 before_app_request 挂上去的,未登录时为 None,所以判断要加g.user and。flash 消息用 with_categories=true,模板才能拿到 ok/error 分类,避免所有提示一个颜色。
4.2 表单校验与错误回显:不用 WTForms 也能做
申请表单用原生 HTML 控件,房间下拉从数据库取 open 状态的房间,时间用 datetime-local,备注限制 200 字。前端 required 只能拦住空值,时间先后、超过 8 小时、时间段冲突仍然要靠后端 validate_loan_form 和冲突查询。错误通过 flash 回显,页面刷新后表单内容不保留,这是 Demo 的取舍;要保留可以再把 request.form 传回模板。
<!-- app/templates/loans/apply.html --> {% extends "base.html" %} {% block title %}提交借用申请{% endblock %} {% block content %} <form method="post"> <label>房间 <select name="room_id" required> {% for room in rooms %} <option value="{{ room['id'] }}">{{ room['name'] }}({{ room['capacity'] }} 人)</option> {% endfor %} </select> </label> <label>开始时间 <input type="datetime-local" name="start_at" required></label> <label>结束时间 <input type="datetime-local" name="end_at" required></label> <label>用途说明 <textarea name="note" maxlength="200"></textarea></label> <button type="submit">提交申请</button> </form> {% endblock %}datetime-local提交的值就是YYYY-MM-DDTHH:MM,和datetime.fromisoformat对得上;如果换成 text 输入,用户很可能敲2025/03/01 9:00,后端解析直接失败。maxlength="200"只是浏览器限制,后端仍要截断或校验 note 长度。房间下拉只查 open,关闭的房间不会出现在选项里,但历史单据里的 room_id 仍然能关联出房间名。
4.3 看板 SQL 与分页:让答辩时有一页能讲
看板按房间聚合,统计总借用单、进行中的单、逾期单。LEFT JOIN 保证没有借用的房间也出现在列表里,SUM(CASE WHEN ...) 把不同状态压成列。列表页用 LIMIT/OFFSET 分页,page 从 query string 取,非法值压到 1,总页数用 ceil 算。
SELECT r.name, COUNT(l.id) AS total, SUM(CASE WHEN l.status IN ('approved', 'borrowed', 'overdue') THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN l.status = 'overdue' THEN 1 ELSE 0 END) AS overdue FROM room r LEFT JOIN loan l ON l.room_id = r.id GROUP BY r.id, r.name ORDER BY overdue DESC, active DESC, r.name;# app/loans.py 里的列表分页节选 from math import ceil from flask import request, render_template from .db import get_db def list_page(): db = get_db() page = max(request.args.get("page", 1, type=int), 1) per_page = 10 offset = (page - 1) * per_page total = db.execute("SELECT COUNT(*) AS n FROM loan").fetchone()["n"] rows = db.execute( """SELECT l.*, r.name AS room_name, u.username FROM loan l JOIN room r ON r.id = l.room_id JOIN user u ON u.id = l.user_id ORDER BY l.created_at DESC LIMIT ? OFFSET ?""", (per_page, offset), ).fetchall() return render_template( "loans/list.html", loans=rows, page=page, pages=ceil(total / per_page) if total else 1, )聚合 SQL 里 active 把 approved、borrowed、overdue 都算作占用中,overdue 单独再算一列,排序时逾期多的房间排前面,答辩现场一眼能看到问题房间。分页代码里request.args.get(..., type=int)自带类型转换,传字母会得到默认值 1;max(..., 1)防止 page=0 或负数。LIMIT 和 OFFSET 用参数占位,不拼字符串,避免注入。总页数为 0 时给 1,模板不会出现“第 1 页 / 共
本文还有配套的精品资源,点击获取