☰
数据库课设实战:Python+Flask+SQLite图书租借系统从零实现
2026/10/16 4:12:55 网站建设 项目流程

从标题看,"第三次作业"这四个字确实平平无奇,但如果你经历过那种"前两次作业都是小打小闹,第三次直接上完整应用"的课程设计,就会明白这个标题背后藏着多少东西。我这次要拆解的是某高校数据库课程设计的第三次作业——用Python连接数据库,独立完成一个带前端界面、能跑通增删改查的图书租借管理系统。作业本身不难,但它是从"会写SQL"到"会做系统"的分水岭,很多同学恰恰栽在这一步。这篇文把整个设计过程、踩坑记录和避坑清单全整理出来,给正在跟同类作业较劲的人做一个参考。

1. 作业背景与整体设计思路

1.1 三次作业的递进关系与这次的目标

先说背景。这门课的第一次作业是拿一张销售订单表做SQL练习,核心考点是 SELECT、JOIN、GROUP BY 这些基础查询;第二次作业是在订单库上写存储过程、触发器和视图,开始接触"数据库编程"。到了第三次作业,老师只给了一句话:基于数据库做一个完整的应用系统,表结构自己设计,后端至少三个接口,前端至少两个页面,数据量不少于20条,功能自选。

这个命题方式其实是有设计逻辑的——前两次作业把"单点技能"都考完了,第三次就是综合应用题。它的真实考察点有三个:第一,你会不会根据业务需求设计合理的表结构;第二,你会不会在应用层正确处理数据的完整性和一致性;第三,你会不会把SQL、后端逻辑和页面串成一个完整闭环。

我选的是图书租借管理系统。理由很朴素:图书租借的业务场景非常清晰,核心实体就三个——用户、图书、借阅记录,天然覆盖多表关联、库存校验、事务提交这些关键知识点,又不会像电商系统那样复杂到超出作业范围。选项目的时候有个判断标准:实体关系清楚的项目,才容易把分数拿到手;实体关系模糊的项目,光是理清逻辑就能耗掉一半时间。

1.2 技术方案选型与我的取舍逻辑

当时考虑过三套方案,分别做了对比:

方案优点缺点我的结论
Python + Flask + SQLite环境轻量,无需安装数据库服务,SQL语句完全可见不适合高并发场景作业规模下最稳
Java + Spring Boot + MySQL工程规范,MySQL通用性强环境配置重,启动慢,容易被环境问题拖死过度设计
Node.js + Express + MongoDB前后端同语言数据用JSON存,评分老师不熟悉偏离作业方向

最后选了 Python + Flask + SQLite,核心原因就一条:课程机房的机器配置有限,Java那套光启动就要吃几百兆内存,而且MySQL服务一旦没配好,作业演示就成了"修环境演示"。SQLite把数据库直接放在项目目录里,拷贝整个文件夹就能跑,这对评分和演示都是最友好的。

关于数据库访问层,我没有用 SQLAlchemy ORM,而是直接写原生 sqlite3。一来是作业要求里明确写了"不能隐藏SQL语句",二来是我觉得这个阶段应该把SQL基本功展现在评分老师面前。用ORM虽然代码更少,但和这门课的核心考点是背道而驰的。

2. 数据库设计:三张表、约束与索引的取舍

2.1 用户表与图书表的结构

数据库设计是整个作业的地基,地基不稳,后面接口写得再漂亮也可能出逻辑漏洞。我用的是最经典的三张表设计:用户表 user、图书表 book、借阅流水表 borrow_record。

用户表结构如下:

CREATE TABLE user ( user_id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, real_name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'student' CHECK (role IN ('student', 'admin')), created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) );

username 加 UNIQUE 约束是给登录功能和注册功能兜底,避免出现两个同名账号。role 字段用 CHECK 约束限定只能是 student 或 admin,这是给后续扩展"管理员可以新增图书"这类权限功能留的口子。created_at 直接取数据库本地时间,比在应用层写时间更可靠,这是第一个小技巧。

图书表结构如下:

CREATE TABLE book ( book_id INTEGER PRIMARY KEY AUTOINCREMENT, isbn TEXT NOT NULL UNIQUE, title TEXT NOT NULL, author TEXT NOT NULL, category TEXT, total_copies INTEGER NOT NULL DEFAULT 1 CHECK (total_copies >= 0), available_copies INTEGER NOT NULL DEFAULT 1 CHECK (available_copies >= 0 AND available_copies <= total_copies) );

这里有一个很关键的取舍:我故意在 book 表里冗余了一个 available_copies 字段,用来记录"当前可借数量",而不是每次借书时用"total_copies 减去已借数量"的聚合查询去算。

这个设计的代价是业务层每次借还书都要多更新一行数据,但换来的是查询极快、逻辑直观——首页显示图书列表时,直接读 available_copies 就能判断"可借/已借完",不用写子查询。这是典型的读多写少场景,用冗余换性能,在低并发的作业系统里非常合理。

有一个细节必须说明:数据库的 CHECK 约束只能保证 available_copies 不小于0且不超过 total_copies,但无法保证两个字段之和是否符合真实业务。比如我借出一本书,结果忘了更新 available_copies,两个字段在数值上依然满足约束,但业务上已经错了。所以应用层的事务处理才是保障一致性的关键,后面接口部分会细讲。

2.2 借阅流水表的结构与主键设计

借阅流水表是这题的得分关键,也是最容易设计出错的地方:

CREATE TABLE borrow_record ( record_id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, book_id INTEGER NOT NULL, borrow_date TEXT NOT NULL, due_date TEXT NOT NULL, return_date TEXT, status TEXT NOT NULL DEFAULT 'borrowed' CHECK (status IN ('borrowed', 'returned', 'overdue')), FOREIGN KEY (user_id) REFERENCES user (user_id), FOREIGN KEY (book_id) REFERENCES book (book_id) );

很多人会纠结:既然一个用户一次只能借一本,为什么不直接拿 (user_id, book_id, borrow_date) 做联合主键,还要单独加一个 record_id?

答案是:联合主键在"还书后再借同一本"的场景下会撞车。假设 A 同学今天借了《数据库原理》,还掉之后明天又想借,这时候如果主键是 (user_id, book_id, borrow_date),两天日期不同倒是不冲突,但如果同一天内借了再还、再借,borrow_date 精确到天就会把两条记录当成同一条数据。借阅流水是典型的流水型数据,要求每条记录都能被唯一标记,单独设自增 record_id 是最稳妥的方案。

另外我还加了一个部分唯一索引,防止同一个人同时对同一本书处于借出状态:

CREATE UNIQUE INDEX idx_active_borrow ON borrow_record (book_id, user_id) WHERE status = 'borrowed';

这是 SQLite 支持的部分索引写法。它的意思是:只在 status 为 borrowed 的记录上保证 (book_id, user_id) 不重复。这个索引一加,就算后端接口漏写了库存判断,数据库层也能挡住"一个人借同一本书两次"的错误,属于双保险。

2.3 索引、约束与初始数据

外键字段虽然写在表定义里,但 SQLite 默认不会自动为它们创建索引。如果后续要按 user_id 查某人的借阅记录,不加索引就是全表扫描。所以我额外加了两个普通索引:

CREATE INDEX idx_record_user ON borrow_record (user_id); CREATE INDEX idx_record_book ON borrow_record (book_id);

索引不是越多越好,作业规模下这两个就够了。status 字段我其实也可以加索引,但考虑到数据量小且不是主要查询条件,就省了。

初始数据方面,我插入了3个用户和20本书。有个实操小技巧:插入书本数据时,故意把两本书设置成 total_copies 为 2、available_copies 为 2,这样演示时能展示"同一本书有两个副本"的库存扣减逻辑;再留一两本 available_copies 为 0 的书,用来展示"已借完不可借"的提示分支。这些边界数据不是凑数的,是专门为作业验收时的各种提问准备的。

3. 后端接口实现:从登录到借还书

3.1 项目结构与数据库连接

后端我用 Flask,项目结构很干净:

library_app/ ├── app.py # 主应用,所有路由都在这 ├── db.py # 数据库连接与初始化 ├── init_data.py # 建表 + 插入初始数据 ├── templates/ │ ├── login.html # 登录页 │ ├── index.html # 图书列表首页 │ └── add_book.html # 新增图书页 ├── static/ │ └── style.css # 页面样式 ├── library.db # SQLite 数据库文件 └── requirements.txt # 依赖清单

db.py 里的连接函数是全局复用的关键:

import sqlite3 from flask import g DATABASE = 'library.db' def get_db(): if 'db' not in g: g.db = sqlite3.connect(DATABASE) g.db.row_factory = sqlite3.Row g.db.execute('PRAGMA foreign_keys = ON') return g.db

有三处细节值得展开。

第一,每次请求都用 Flask 的 g 对象缓存连接,请求结束再关闭,避免反复创建连接。第二,row_factory 必须设为 sqlite3.Row,否则取出来的数据只能按数字下标访问,写代码极其痛苦且容易出错。第三,PRAGMA foreign_keys = ON 这行一定要加——SQLite 默认不开启外键约束,不加这行,前面设计的外键和级联逻辑全部形同虚设。这个坑我见过太多人踩了。

init_data.py 负责建表和插入数据,核心逻辑是先 DROP 再 CREATE,保证重复执行不会报错:

from db import DATABASE import sqlite3 def init_db(): conn = sqlite3.connect(DATABASE) conn.executescript(''' DROP TABLE IF EXISTS borrow_record; DROP TABLE IF EXISTS book; DROP TABLE IF EXISTS user; -- 建表语句省略,见第二章 ''') # 插入初始用户、图书 conn.commit() conn.close()

这里我故意把 DROP 顺序写成了先删子表(borrow_record)、再删父表(book、user),因为外键约束下删除顺序反了会报错。这个细节在项目演示前检查一遍很重要。

3.2 登录校验与访问控制

登录接口最大的坑在于SQL拼接。我刚写第一版时直接用了 f-string 拼接用户名和密码,虽然简单,但这是明显的注入漏洞入口。后来改成了参数化查询:

@app.route('/login', methods=['POST']) def login(): username = request.form.get('username', '').strip() password = request.form.get('password', '').strip() if not username or not password: return '用户名和密码不能为空', 400 db = get_db() user = db.execute( 'SELECT user_id, username, real_name, role FROM user WHERE username = ? AND password = ?', (username, password) ).fetchone() if not user: return '用户名或密码错误', 401 session['user_id'] = user['user_id'] session['username'] = user['username'] session['role'] = user['role'] return redirect(url_for('index'))

使用?占位符加参数元组,让数据库驱动去处理转义,这是最基础也最重要的安全习惯。

关于密码存储,说实话作业里用明文是可接受的,因为评分老师需要直接查库看数据。但我要提醒一句:真实项目中绝对不能用明文存密码,至少要加盐后做 PBKDF2 或 bcrypt 哈希。作业可以讲道理,开发必须讲安全。

登录后的页面访问控制,我用了一个 before_request 钩子统一拦截,而不是在每个路由里重复写判断:

@app.before_request def require_login(): allowed = {'login', 'static'} if request.endpoint not in allowed and 'user_id' not in session: return redirect(url_for('login'))

这样新加页面路由时不用想着补权限判断,钩子会自动兜底。所有需要登录的页面都在进路由之前被检查,规范且省事。

3.3 借书和还书的事务处理

借书接口是整个作业的重头戏,逻辑顺序很容易漏。我的实现如下:

@app.route('/borrow/<int:book_id>', methods=['POST']) def borrow_book(book_id): db = get_db() user_id = session.get('user_id') today = datetime.date.today() due_date = today + datetime.timedelta(days=30) try: book = db.execute( 'SELECT total_copies, available_copies FROM book WHERE book_id = ?', (book_id,) ).fetchone() if not book: return '图书不存在', 404 if book['available_copies'] <= 0: return '该书暂无可借副本', 400 active = db.execute( 'SELECT record_id FROM borrow_record ' 'WHERE book_id = ? AND user_id = ? AND status = "borrowed"', (book_id, user_id) ).fetchone() if active: return '你已借用该书,请先归还', 400 db.execute( 'INSERT INTO borrow_record (user_id, book_id, borrow_date, due_date, status) ' 'VALUES (?, ?, ?, ?, "borrowed")', (user_id, book_id, today.isoformat(), due_date.isoformat()) ) db.execute( 'UPDATE book SET available_copies = available_copies - 1 WHERE book_id = ?', (book_id,) ) db.commit() return redirect(url_for('index')) except sqlite3.IntegrityError: db.rollback() return '操作失败,请稍后重试', 400

先说判断顺序。第一条查图书是否存在,第二条判库存,第三条判当前用户是否已借过同本书,全部通过才写入借阅记录并扣库存。顺序不能乱,如果先判重复再查库存,某个用户同时触发两个请求时可能都通过检查,造成超借。

再说事务。SQLite 默认每条 DML 语句是自动提交的,如果不手动控制事务,INSERT 成功但 UPDATE 失败时库存就被扣了。我在 try 里执行多条写操作,最后统一 commit,任何一步抛异常就 rollback。这是小型系统里最朴素也最有效的事务用法。

我还测试过一个细节:available_copies 会不会被扣成负数。理论上库存判断已经挡住,但为了绝对保险,后来的版本把扣减语句改成了条件更新:

db.execute( 'UPDATE book SET available_copies = available_copies - 1 ' 'WHERE book_id = ? AND available_copies > 0', (book_id,) )

然后检查 rowcount,如果为0说明更新没生效,说明中间有竞态。这一步等于把"判库存"和"扣库存"合并成了一个原子操作。既是代码层面的最佳实践,也是答辩时能说给老师听的亮点。

还书接口是借书的逆过程,有一个容易被忽略的判断点:

@app.route('/return/<int:record_id>', methods=['POST']) def return_book(record_id): db = get_db() today = datetime.date.today() record = db.execute( 'SELECT book_id, due_date, status FROM borrow_record WHERE record_id = ?', (record_id,) ).fetchone() if not record: return '借阅记录不存在', 404 if record['status'] == 'returned': return '该记录已归还', 400 db.execute( 'UPDATE borrow_record SET return_date = ?, status = "returned" ' 'WHERE record_id = ?', (today.isoformat(), record_id) ) db.execute( 'UPDATE book SET available_copies = available_copies + 1 ' 'WHERE book_id = ?', (record['book_id'],) ) db.commit() return redirect(url_for('index'))

注意我没有把超期判断写进状态更新,而是保持 status 为 borrowed,只在页面显示时计算是否超期。原因很简单:一条借阅记录的核心语义是"借了没还",超期是一种业务标注,单独改状态会把简单模型搞复杂。真要统计超期时,一条WHERE due_date < date('now') AND status = 'borrowed'就够用了,不需要提前改状态。

3.4 图书检索的参数化查询

图书列表页支持按书名模糊搜索和按分类筛选。模糊搜索用 LIKE 时,照样用参数化:

@app.route('/') def index(): keyword = request.args.get('q', '').strip() category = request.args.get('category', '').strip() sql = 'SELECT * FROM book WHERE 1 = 1' params = [] if keyword: sql += ' AND title LIKE ?' params.append(f'%{keyword}%') if category: sql += ' AND category = ?' params.append(category) sql += ' ORDER BY book_id DESC' books = db.execute(sql, params).fetchall() return render_template('index.html', books=books)

注意这里的 LIKE 拼接,参数本身用%包裹,而不是把%写进 SQL 再拼字符串。这样%是查询条件的一部分,不是 SQL 语法的一部分,不会破坏语句结构。

4. 前端页面与联调踩坑记录

4.1 页面与接口的对应设计

前端我只写了三个页面:登录页、图书列表页、新增图书页,完全满足作业要求。页面之间用最简单的 HTML 表单 + Jinja2 模板渲染,没有引入任何前端框架,因为这门课的考点是数据库,不是前端工程化,没必要给自己加戏。

index.html 的核心表格片段长这样:

<table> <tr> <th>标题</th><th>作者</th><th>分类</th> <th>库存</th><th>操作</th> </tr> {% for book in books %} <tr> <td>{{ book['title'] }}</td> <td>{{ book['author'] }}</td> <td>{{ book['category'] }}</td> <td>{{ book['available_copies'] }}/{{ book['total_copies'] }}</td> <td> {% if book['available_copies'] > 0 %} <form action="/borrow/{{ book['book_id'] }}" method="post"> <button type="submit">借书</button> </form> {% else %} <span>已借完</span> {% endif %} </td> </tr> {% endfor %} </table>

这里有个很重要的细节:借书按钮我用了<form method="post">而不是<a href="/borrow/1">。这两种写法的差别很多人意识不到,但实际影响很大。用链接的方式本质是 GET 请求,浏览器刷新页面时会重新发起请求,用户不小心刷新一下就重复借了一次书。改成 POST 表单后,刷新会触发浏览器的"确认重新提交"提示,从交互层面就挡住了误操作。这个知识点在答辩时非常加分,因为多数同学想都没想过。

4.2 联调顺序与典型问题

联调阶段我按这个顺序测:登录 → 图书检索 → 借书 → 重复借同一本 → 库存不足 → 还书 → 超期判断。这个顺序的策略是先测主链路、再测异常分支,保证每个环节只依赖前面已经验证过的部分。

实际联调中遇到一个典型的样式问题:登录成功后页面正常跳转,但 CSS 全部丢失,页面变成了纯文本。排查后发现不是代码问题,而是浏览器从登录页跳转到首页时 URL 从/login变成了/,静态文件的相对路径解析发生了变化。解决办法是在模板里用 Flask 的url_for('static', filename='style.css')生成完整路径,而不是写相对路径。这也是新手最容易忽略的环节。

另一个问题是 session 时长。Flask 默认的 session 是浏览器会话级,关掉浏览器就失效。作业演示时如果演示到一半去调代码,重新打开页面又要登录,体验很糟。我在 app.py 里加了两行:

app.config['SECRET_KEY'] = 'some-random-key' app.config['PERMANENT_SESSION_LIFETIME'] = datetime.timedelta(hours=2)

并且登录成功时把session.permanent设为 True,这样演示两小时内不用反复登录。SECRET_KEY 我用了随机字符串,生产环境必须用环境变量注入,不能写死在代码里。

5. 常见问题与避坑速查表

5.1 报错与排查对照表

我把这次作业里最容易出现的报错和排查方向整理成一张对照表,遇到问题先对号入座:

报错现象可能原因排查方向
sqlite3.OperationalError: no such table没执行 init_data.py,或表名拼写不一致先跑一次初始化脚本,再核对建表语句
sqlite3.IntegrityError: UNIQUE constraint failed插入了重复的 username 或 isbn查初始数据和表单输入是否撞车
登录后 session 没生效app.secret_key 未配置检查是否设置了 SECRET_KEY
点击借书按钮直接 500book_id 这个参数没正确传到路由模板里确认 form action 已绑定 book_id
中文乱码模板没声明字符编码在 HTML head 里加 meta charset="utf-8"
5000 端口被占用上次 Flask 进程没有正常退出查找进程并 kill,或改用其他端口
available_copies 出现负数借书接口的库存判断和扣减不在一个事务里检查 UPDATE 是否带 available_copies > 0 条件

另外补一条外人很少提的:SQLite 的 datetime 函数在不同系统上返回格式可能略有差异,比如有的带毫秒有的不带。为了避免这种不可控因素,我所有时间字段都是用 Python 的date.today().isoformat()直接生成字符串写入,格式统一为 YYYY-MM-DD,数据库端不做任何转换。这样跨平台演示也不会出问题。

5.2 我实际踩过的三个坑

第一个坑是外键约束没生效。最初我写完所有表结构和高高兴兴开始写接口,结果测试删除一个用户时,借阅记录居然还在。查了半天才发现 SQLite 默认外键是关闭的,不执行PRAGMA foreign_keys = ON,外键约束全是摆设。这个坑最隐蔽,因为建表语句没有报错,一切看起来正常,只有实际删除时才暴露。

第二个坑是我前面提到过的"库存扣成负数"。其实我在测试时故意用两个浏览器标签页同时点借书按钮,纯靠"先查询再更新"的逻辑确实会产生两次查询都读到库存为1、然后两次都执行扣减,最后可用库存变成-1。后来改成带条件的 UPDATE 加 rowcount 判断,这个漏洞才堵死。多人同时操作的并发问题,在单机演示时看不出来,但一旦写到简历上就是硬伤。

第三个坑比较反直觉:删除图书时要考虑借阅记录里的外键引用。如果某本书还有未归还的借阅记录,直接 DELETE book 会被外键约束挡住报错。我一开始想用ON DELETE CASCADE,这样删书时自动删掉相关借阅记录。但仔细一想不对——如果管理员误删了一本仍在借的书,连带借阅记录全没了,学生的借书状态就丢了。最后我选择了最保守的规则:有未归还记录的书不允许删除,只能把 total_copies 设为0停用。这个设计适合"作业答辩时老师问为什么不用级联删除"的问题,逻辑自洽且有业务考量。

结尾

做完这次作业,我最深的体会是:数据库课设的难点从来不在写代码,而在设计决策。要不要冗余库存字段、要不要加部分唯一索引、外键冲突时怎么取舍,这些选择没有绝对的对错,但每个选择都要能说出理由。第三次作业之所以让人头疼,就是因为它逼着你去思考这些"为什么",而不是照着教材敲一遍 SQL。

最后分享一个扩展方向,后续如果想让这个系统更完整,可以加一个统计报表页面,用一条SELECT book_id, COUNT(*) FROM borrow_record WHERE status = "returned" GROUP BY book_id统计每本书的借阅次数排行。这是最自然的扩展,也正好用上全文里一直在强调的 JOIN 和 GROUP BY 基本功。另外提交作业前记得把演示录屏和截图整理好,光有能跑的代码,评分老师没时间在你电脑上挨个点,预留一份操作说明能让验收顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询