ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flask+MySQL图书管理系统实战:从建库到避坑完整指南

Flask+MySQL图书管理系统实战:从建库到避坑完整指南 简介一份基于Flask框架实现的图书管理系统源代码面向数据库课程期中作业或Web开发入门学习者。项目以轻量级Flask为核心结合数据库存储与Jinja2模板引擎完整实现图书信息添加、删除、检索等常规功能并涉及URL路由、视图函数、Flask-WTF表单处理、SQLAlchemy数据模型定义等关键开发环节可帮助读者快速掌握Flask与数据库结合的应用构建思路同时理解数据库表结构设计和查询操作。压缩包整体约1.03MB项目文件主要包含Python源码、模板及静态资源目录结构分明便于直接运行和二次扩展。已有355人浏览学习适合作为课程设计参考或实战练习在此基础上可继续加入用户认证、权限控制等安全机制使系统更接近生产环境。1. 用Flask做图书管理系统为什么期中作业值得按生产标准做用Flask实现图书管理系统听起来是数据库期中作业里的标准配置但真正动手时你会发现表结构设计、ORM配置、借阅状态流转每一个环节都有坑。这篇笔记不打算只给你贴一份能跑的代码而是把从建库到交作业的完整路径拆开——选什么数据库、表字段怎么定、增删改查怎么写、为什么会遇到MySQL server has gone away这类报错。按生产标准做这份作业有一个直接好处答辩时能讲出每个设计决策的理由以后接真实业务也不用推翻重来。适合正要交数据库课程作业、想搭一个内部书籍登记工具、或者刚学Flask想拿真实项目练手的读者。我默认你已经装好了Python和MySQL命令行能敲mysql -uroot -p进得去。2. 数据库先行三张核心表的设计与MySQL建表SQL2.1 三张核心表字段设计的原则与冗余取舍图书管理系统的数据模型常见的错误是一张表走天下。用户、图书、借阅记录全塞一张表查询是方便了但帐面根本对不平。我拆成三张表users、book、borrow_record。users表存读者和管理员字段用username、password_hash、role、created_at。password_hash强调存哈希而不是明文很多作业里把密码直接存成字符串老师一眼就能看出来。role用整数0是管理员、1是普通用户页面显示时再映射成中文比直接存管理员三个字更利于后面加角色。book表是核心除基本书目信息外我建议保留total_copies和available_copies两个字段。available_copies是冗余字段因为理论上它可以由借阅记录现场count出来。但图书管理场景并发不高冗余一个计数字段列表页就不用每次联表做聚合响应快很多。代价是借书、还书时必须在一个事务里同时更新它后面第5章避坑会讲到。borrow_record表记录每次借还动作字段是book_id、user_id、borrow_date、due_date、return_date、status。status用0借出、1已还、2逾期三个整数。逾期不落库计算而是查询时判断return_date为空且due_date小于今天就是逾期。这样设计能让状态流转只出现在借书和还书两个操作里逻辑干净。外键我建议都在book_id和user_id上加删除策略用RESTRICT。有人借了这本书时书就删不掉正好逼着系统在删除前先做业务检查。2.2 MySQL建表SQL直接执行就能用的脚本CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE library_db; CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password_hash VARCHAR(255) NOT NULL, role TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL, title VARCHAR(200) NOT NULL, author VARCHAR(100) NOT NULL, publisher VARCHAR(100), category VARCHAR(50), total_copies INT NOT NULL DEFAULT 1, available_copies INT NOT NULL DEFAULT 1, publish_date DATE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_isbn (isbn), KEY idx_title (title), KEY idx_category (category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, user_id INT NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL, return_date DATE, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book (id) ON DELETE RESTRICT, CONSTRAINT fk_borrow_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT, KEY idx_borrow_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段SQL有几个值得注意的地方。第一建库时直接定utf8mb4而不是utf8因为MySQL里的utf8最多存3字节遇到生僻字或表情符号会报错utf8mb4才是完整UTF-8。第二book表的isbn加唯一索引重复录入在数据库层就被拦住应用层只需要负责给用户提示。第三借阅记录的外键都带了约束名后面想改删除策略时用ALTER TABLE ... DROP FOREIGN KEY操作不用去翻系统表查默认生成的名字。注意users表名避开MySQL里user这个词因为mysql.user是系统库自带表业务表叫user容易在各种工具里造成混淆带个s更稳妥。测试数据我建议顺手插入三本不同类型的书让列表页、检索页一打开就有内容不用调试时对着一张空表发愁。ISBN可以用自己手头的书编一个不冲突的字符串即可。INSERT INTO book (isbn, title, author, publisher, category, total_copies, available_copies, publish_date) VALUES (TEST000001, 红楼梦, 曹雪芹, 人民文学出版社, 古典文学, 5, 5, 1996-12-01), (TEST000002, 深入理解计算机系统, 兰德尔·E.布莱恩特, 机械工业出版社, 计算机, 3, 2, 2016-11-01), (TEST000003, Python编程从入门到实践, 埃里克·马瑟斯, 人民邮电出版社, 计算机, 4, 0, 2020-10-01);注意第三本书available_copies故意设成0这是全部借出状态调试还书流程时能立刻看出区别。2.3 ORM还是原生SQL期中作业选哪条路建表可以用原生SQL但业务代码里的增删改查我建议走ORM具体就是flask-sqlalchemy配pymysql。一是可读性好一行Book.query.filter_by(category计算机).all()答辩时能直接念出来它在问什么原生SQL拼接容易掺进字符串格式化一不留神就是SQL注入风险作业阶段没必要冒险。二是表结构变更成本低做到一半要加字段ORM加一行属性定义配合迁移工具就能搞定原生SQL得手写ALTER TABLE再同步改所有INSERT语句里的列名漏一处就翻车。三是不存在作业用了ORM显得不高级的问题SQLAlchemy同样暴露text()让你执行复杂SQL只是把常规操作包得更顺手而已。我习惯先用原生SQL把表建出来确认字段、索引、外键都没问题再回头写ORM模型类。这样两边都覆盖到答辩被问底层到底执行了什么SQL时你能直接答出来。3. Flask应用骨架从空白目录到能跑起最小系统3.1 目录结构与三个前提先讲清楚前提。Python版本建议3.10以上太老的环境flask-sqlalchemy依赖解析会有兼容问题。MySQL要能连上本地3306端口没被占用。第三个前提是虚拟环境把依赖装进项目自己的venv目录不要污染全局Python。目录结构我固定成这样期中作业规模恰好够用library_system/ ├── app/ │ ├── __init__.py │ ├── config.py │ ├── models.py │ ├── views/ │ │ ├── __init__.py │ │ ├── book.py │ │ ├── user.py │ │ └── borrow.py │ ├── templates/ │ └── static/ ├── venv/ ├── run.py └── requirements.txtviews里的三个文件分别对应图书、用户、借阅三个蓝图。拆开的理由是后面加功能时加文件比在单个app.py里堆路由要轻松得多。你不想答辩时翻开一个800行的app.py找某个路由要滚半天。3.2 安装依赖从零到能importcd library_system python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install flask flask-sqlalchemy pymysql python-dotenv pip freeze requirements.txt安装完检查一下pip list里的Flask版本。flask-sqlalchemy对Flask版本有下限要求旧教程里init_app的返回值写法在3.x里已经变了如果照着老代码抄可能拿不到预期对象。requirements.txt是给别人复现用的交作业默认带一份。3.3 配置文件DATABASE_URL里的每个参数都是什么意思config.py是整个项目最容易被忽略又最值得花时间的地方。我把连接信息放进环境变量本地开发用.env兜底部署到服务器时直接改环境变量不用动代码。import os from dotenv import load_dotenv load_dotenv() class Config: SECRET_KEY os.getenv(SECRET_KEY, dev-secret-key-change-me) SQLALCHEMY_DATABASE_URI os.getenv( DATABASE_URL, mysqlpymysql://root:123456127.0.0.1:3306/library_db?charsetutf8mb4 ) SQLALCHEMY_TRACK_MODIFICATIONS False SQLALCHEMY_ENGINE_OPTIONS { pool_size: 5, pool_recycle: 3600, pool_pre_ping: True }拆开讲每个参数。mysqlpymysql是方言加驱动的写法以后要连PostgreSQL或SQLite只需换前缀业务代码不用动。连接串里密码若有特殊字符比如要先做URL编码否则SQLAlchemy解析时会在错误位置截断。charsetutf8mb4必须带这和第2章建库时指定的字符集呼应三层字符集少一层都会乱码。pool_recycle3600是让连接池里的连接每3600秒强制重建一次配合MySQL默认的wait_timeout能避免MySQL server has gone away这类报错。pool_pre_pingTrue则是每次取连接前先ping一下失效连接直接丢弃换新的。这两个参数是血泪经验换来的作业阶段加上能少踩一半坑。3.4 应用工厂与蓝图为什么把路由拆开app/__init__.py里用应用工厂模式创建Flask实例。理由只有一个方便测试。后面写冒烟测试时每次create_app()都能拿到一个全新配置的应用测试之间不会互相污染。from flask import Flask from flask_sqlalchemy import SQLAlchemy db SQLAlchemy() def create_app(): app Flask(__name__) app.config.from_object(app.config.Config) db.init_app(app) from app.views.book import bp as book_bp from app.views.user import bp as user_bp from app.views.borrow import bp as borrow_bp app.register_blueprint(book_bp) app.register_blueprint(user_bp) app.register_blueprint(borrow_bp) return app蓝图文件里的标准写法是from flask import Blueprint, render_template bp Blueprint(book, __name__, url_prefix/books) bp.route(/) def index(): return render_template(book_list.html)url_prefix/books的意思是这个蓝图里的所有路由都自动带/books前缀书相关的增删改查全部从这里进出。run.py放在项目根目录是最外层的启动入口from app import create_app app create_app() if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)开发阶段开着debugTrue改完代码自动重载不用手动重启这对赶作业特别友好。部署到服务器上时你不会用app.run而是用gunicorn拉起这个create_app()返回的实例但本地开发和验收用app.run就够了。上线前debugTrue一定关掉否则访问者能看到完整堆栈和敏感信息。4. 图书增删改查ORM怎么用才能省一半代码4.1 模型类定义字段类型、默认值与关系models.py里把三张表翻译成Python类。字段类型要和建表SQL严格对齐DATETIME用db.DateTimeDATE用db.DateTINYINT用db.SmallInteger。对齐不一致时最常见的症状是日期字段读出来是字符串模板里没法直接调用strftime。from app import db from datetime import datetime class User(db.Model): __tablename__ users id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(50), uniqueTrue, nullableFalse) password_hash db.Column(db.String(255), nullableFalse) role db.Column(db.SmallInteger, default1, nullableFalse) created_at db.Column(db.DateTime, defaultdatetime.now) class Book(db.Model): __tablename__ book id db.Column(db.Integer, primary_keyTrue) isbn db.Column(db.String(20), uniqueTrue, nullableFalse) title db.Column(db.String(200), nullableFalse) author db.Column(db.String(100), nullableFalse) publisher db.Column(db.String(100)) category db.Column(db.String(50)) total_copies db.Column(db.Integer, default1, nullableFalse) available_copies db.Column(db.Integer, default1, nullableFalse) publish_date db.Column(db.Date) created_at db.Column(db.DateTime, defaultdatetime.now) def __repr__(self): return fBook {self.isbn} {self.title} class BorrowRecord(db.Model): __tablename__ borrow_record id db.Column(db.Integer, primary_keyTrue) book_id db.Column(db.Integer, db.ForeignKey(book.id), nullableFalse) user_id db.Column(db.Integer, db.ForeignKey(users.id), nullableFalse) borrow_date db.Column(db.Date, nullableFalse) due_date db.Column(db.Date, nullableFalse) return_date db.Column(db.Date) status db.Column(db.SmallInteger, default0, nullableFalse) book db.relationship(Book, backrefdb.backref(borrow_records, lazydynamic)) user db.relationship(User, backrefdb.backref(borrow_records, lazydynamic))relationship是ORM相比原生SQL最舒服的地方。查出一条借阅记录后直接取record.book.title就能拿到书名不用手写JOIN。lazydynamic让反向引用变成一个查询对象可以继续调用.filter()而不是一访问就把所有记录灌进内存。4.2 图书列表页分页、模糊检索和关键字回显图书列表页是系统门面一上来要解决三件事分页、关键字搜索、搜索词回显。分页用flask-sqlalchemy自带的paginate不用手动算offset。from flask import Blueprint, render_template, request from app.models import Book from app import db bp Blueprint(book, __name__, url_prefix/books) bp.route(/) def book_list(): page request.args.get(page, 1, typeint) keyword request.args.get(keyword, , typestr).strip() query Book.query if keyword: like_pattern f%{keyword}% query query.filter( db.or_( Book.title.like(like_pattern), Book.author.like(like_pattern), Book.isbn.like(like_pattern) ) ) pagination query.order_by(Book.created_at.desc()).paginate( pagepage, per_page10, error_outFalse ) return render_template( book_list.html, paginationpagination, keywordkeyword )paginate三个参数值得记page是当前页码per_page是每页条数error_outFalse表示页码超出范围时返回空列表而不是抛404。模板里渲染分页栏用pagination.pages判断总页数用has_prev和has_next控制上一页下一页按钮。关键字回显就是把keyword原样传回模板放在输入框的value里翻页后搜索条件不丢。模糊检索用like是最省方案的写法数据量到几万条时性能会下降但期中作业完全够用。真要到全文检索阶段再考虑MySQL全文索引或上ES现在别过度设计。4.3 新增与编辑表单ISBN查重和日期解析新增图书的表单处理逻辑核心是防重复和数据清洗。前端HTML的必填校验是给用户友好提示真正拦住脏数据的是后端这一段。from datetime import datetime from flask import request, redirect, url_for, flash def parse_date(value): if not value: return None try: return datetime.strptime(value, %Y-%m-%d).date() except ValueError: return None bp.route(/add, methods[GET, POST]) def book_add(): if request.method POST: isbn request.form.get(isbn, ).strip() title request.form.get(title, ).strip() author request.form.get(author, ).strip() if not isbn or not title or not author: flash(ISBN、书名、作者不能为空) return redirect(url_for(book.book_add)) exists Book.query.filter_by(isbnisbn).first() if exists: flash(fISBN {isbn} 已存在请检查是否重复录入) return redirect(url_for(book.book_add)) total int(request.form.get(total_copies, 1) or 1) book Book( isbnisbn, titletitle, authorauthor, publisherrequest.form.get(publisher, ).strip(), categoryrequest.form.get(category, ).strip(), total_copiestotal, available_copiestotal, publish_dateparse_date(request.form.get(publish_date, )) ) db.session.add(book) db.session.commit() flash(f《{title}》添加成功) return redirect(url_for(book.book_list)) return render_template(book_form.html)两个细节说一下。isbn查重用filter_by精确匹配用first()而不是all()因为只需要知道有没有。total_copies和available_copies同时赋值为total这是新书刚入库的必然状态。新手容易漏写available_copies然后列表页显示有书却一本都借不出去。表单里的日期字段HTML5的date输入会给YYYY-MM-DD字符串但用户可能手输别的格式。parse_date兜底解析失败返回None而不是让页面直接抛异常。这个函数建议全项目共用编辑时还会再遇到一次。4.4 删除必须用POST表单拒绝GET副作用删除图书我坚持只用POST而且是带CSRF令牌的POST。原因是GET请求会被浏览器预加载、爬虫、甚至聊天工具里的链接预览触发。如果删除接口写成/books/3/delete这样的GET用户分享一个链接给别人别人点开就把书删了。这种事故在真实项目里发生过太多次。bp.route(/int:book_id/delete, methods[POST]) def book_delete(book_id): book db.get_or_404(Book, book_id) if book.available_copies book.total_copies: flash(这本书还有未归还的借阅记录不能删除) return redirect(url_for(book.book_list)) db.session.delete(book) db.session.commit() flash(f《{book.title}》已删除) return redirect(url_for(book.book_list))db.get_or_404是flask-sqlalchemy的语法糖取不到记录直接返回404比先query再判None少两行。删除前检查available_copies total_copies是第2章外键RESTRICT约束的业务层补充。数据库层拦住了借出中的书还能删业务层则给用户一个明确提示而不是抛500错误。两层都写答辩时能讲出完整的设计思路。4.5 用户登录与密码哈希作业里最容易被扣分的地方用户模块很容易被做成摆设但密码这件事不能省。期中作业里见过太多password字段直接存明文这是答辩时老师一眼能看出的硬伤。用werkzeug自带的哈希函数几行代码解决。from werkzeug.security import generate_password_hash, check_password_hash from flask import session, flash, redirect, url_for bp.route(/login, methods[GET, POST]) def login(): if request.method POST: username request.form.get(username, ).strip() password request.form.get(password, ) user User.query.filter_by(usernameusername).first() if not user or not check_password_hash(user.password_hash, password): flash(用户名或密码错误) return redirect(url_for(user.login)) session[user_id] user.id session[username] user.username return redirect(url_for(book.book_list)) return render_template(login.html)注册时用generate_password_hash(password)存储登录时用check_password_hash比对。session是Flask自带的会话机制默认签名存在浏览器Cookie里配合第3章那个SECRET_KEY做防篡改。注意SECRET_KEY千万别用默认值上线否则别人可以伪造会话。5. FlaskMySQL的5个典型踩坑现象、原因、解法5.1 中文乱码满天飞现象页面显示一堆问号或者鏄这样的乱码数据库里也存不进中文。原因三层字符集至少有一层不是utf8mb4。第一层是MySQL服务端的character_set_server第二层是建库时指定的字符集第三层是pymysql连接串里的charset参数。最常见的是建库时用了默认latin1或者连接串漏了charsetutf8mb4。解决把第2章的建库语句改成DEFAULT CHARACTER SET utf8mb4连接串里补上?charsetutf8mb4。改完之后重新插入中文数据。已经乱掉的数据没有后悔药只能删库重建所以一开始就定好字符集比事后补救重要。检查当前状态用SHOW VARIABLES LIKE character_set%;这是排查MySQL字符集问题最常用的命令。5.2 MySQL server has gone away现象开发时一切正常挂了一晚上第二天刷新页面突然报OperationalError: (2006, MySQL server has gone away)。重启Flask应用又好了过一阵又复发。原因MySQL默认wait_timeout是8小时连接池里的连接超过这个时间没活动服务端主动断开。SQLAlchemy连接池不知情仍然把旧连接拿出来用于是客户端才发现对端早已关闭。这是Flask开发模式下最典型的玄学问题。解决config.py里的SQLALCHEMY_ENGINE_OPTIONS加pool_recycle3600和pool_pre_pingTrue。pool_recycle让连接在服务端超时前被回收重建pool_pre_ping每次取连接前做一次轻量ping失效连接直接丢弃。这两个参数加上基本整段开发期都不会再碰到这个报错。5.3 外键约束导致删除失败现象删除一本被借走的书页面不报错但数据库弹出foreign key constraint fails或者ORM抛出IntegrityError。原因建表时外键用了ON DELETE RESTRICT这是有意为之。借阅记录表里还有引用这本book_id的记录数据库层不允许级联删除。如果业务层没写检查这个报错就会原样抛到页面上。解决业务层先判断available_copies total_copies再删给出友好提示这是第4章4.4已经写过的逻辑。如果确实想强制删除可以先把关联的borrow_record记录清掉再删书但真实场景中这属于高危操作不建议。保持RESTRICT约束让系统记住有借阅历史的书不能删反而是保护数据完整性的正确选择。5.4 列表页越查越慢N1查询现象图书列表页每页只有10条响应却要几百毫秒。打开SQL日志发现每次渲染要执行几十条SQL其中大部分是重复的联表查询。原因模板里对每条记录访问record.book.title时ORM会懒加载关联对象。10条记录就是10次额外查询加上主查询就是11次。如果页面上还显示借阅人、借阅日期N1问题被进一步放大。数据量小的时候感觉不到数据一多就翻车。解决查询时用joinedload预加载关联表。借阅记录列表页写成BorrowRecord.query.options(joinedload(BorrowRecord.book), joinedload(BorrowRecord.user))一条SQL用LEFT JOIN把两张关联表一起查出来ORM再组装对象。检查有没有N1临时把SQLALCHEMY_ECHO设为True控制台会打印每条SQL循环里的重复查询立刻现形。5.5 GET删除和浏览器预加载现象用户反馈我只是收藏了个链接书就没了或者爬虫访问了一轮数据被删掉一批。原因删除接口用了GET方法URL形如/books/3/delete。GET请求可以被任何地方触发链接预读、浏览器主动预取、聊天工具里的链接预览。只要请求带够参数就会执行删除。解决改成第4章4.4那样的POST表单模板里用form methodpost提交。更进一步用flask-wtf加CSRF令牌防止跨站请求伪造。期中作业阶段至少做到POST答辩时能说出GET请求不应产生副作用这是HTTP规范的要求这句话印象分会高不少。6. 交作业前最后一步冒烟测试、备份与三个进阶方向6.1 冒烟测试测试不监听端口直接用Flask的测试客户端模拟HTTP请求跑起来比手动点页面快得多。准备三个用例列表页可达、新增页可达、删除不存在的书返回404。import pytest from app import create_app pytest.fixture() def client(): app create_app() app.config[TESTING] True with app.test_client() as client: yield client def test_book_list_ok(client): resp client.get(/books) assert resp.status_code 200 def test_book_add_page_ok(client): resp client.get(/books/add) assert resp.status_code 200 def test_book_delete_missing_returns_404(client): resp client.post(/books/99999/delete) assert resp.status_code 404跑pytest之前先pip install pytest然后直接命令行执行。这三个用例能拦住大多数低级错误路由写错、模板找不到、视图函数崩了。6.2 备份与恢复交作业前用mysqldump导一份SQL存档代码改崩了也能回到稳定状态。mysqldump -uroot -p library_db library_db_backup.sql mysql -uroot -p library_db library_db_backup.sql导出的文件是纯SQL可以直接给老师演示数据模型比截图有说服力。恢复命令同样简单注意执行前确认目标库是空的否则会往里追加数据。6.3 三个进阶方向第一个是把视图函数改成返回JSON前端用fetch渲染为前后端分离做准备等以后拿FastAPI做对比时会有更具体的体会。第二个是用Flask-Migrate管理表结构迁移以后再也不用手动ALTER TABLE。第三个是在检索接口上接一层Redis缓存热门查询词这个优化能把响应时间从几十毫秒压到个位数。我还记得第一次做这类系统时连接池参数完全没有概念大半夜被MySQL server has gone away折腾到怀疑人生。后来把pool_recycle和pool_pre_ping加进配置这套组合几乎成了我所有FlaskMySQL项目的默认配置。希望这份笔记能帮你少踩几个同样的坑把图书管理系统做得扎实一点顺利交掉这份作业。本文还有配套的精品资源点击获取
返回列表