资讯动态

数据库大作业实战:Python图书管理系统从E-R图到事务并发完整指南

发布时间:2026/10/10 0:01:21 来源:尧图企业网站定制
简介这份资源是面向高校计算机相关专业学生的数据库课程设计完整方案以Python实现图书管理系统适合期末大作业、课程设计及新手练手场景。压缩包共9个文件约376KB包含4个SQL脚本用于建库建表与数据初始化1个Python主程序承载核心业务逻辑另附E-R图、说明文档、README及gitignore等辅助文件结构紧凑、便于快速部署运行。代码注释较为完整新手也能看懂并在此基础上二次修改。系统功能覆盖图书信息维护、借阅归还、用户与日志管理等常见模块界面简洁、操作直观具备一定实际应用价值。目前已有311人学习下载可作为数据库原理与Python开发结合的参考案例帮助读者理解从E-R建模到脚本落地、再到程序实现的完整链路节省从零搭建的时间成本。1. 数据库大作业的真相图书管理系统为什么是性价比最高的选题每年到了期末总有一批人在群里问「数据库大作业做什么好」。我的答案一直没变过图书管理系统。不是因为它简单而是因为它的业务边界清晰、实体关系完整、增删改查覆盖全面一个题目能把数据库课程里八成以上的知识点串起来。基于 Python 的图书管理系统源码加数据库脚本再配一张 E-R 图这套组合几乎就是数据库大作业的标准答案模板。但这里有个反直觉的结论图书管理系统做得好不好从来不取决于界面多漂亮而取决于你的表结构设计得对不对、约束加得够不够、事务边界划得清不清楚。我见过太多人把精力花在 PyQt 界面上结果数据库脚本里连外键都没加借阅记录删了书还在答辩时被老师一句「你这数据一致性怎么保证」问得哑口无言。这篇笔记就按一线开发的思路把从 E-R 图到可运行系统的完整路径拆开讲适合正在赶大作业的学生也适合想拿它练手数据库设计的开发者。2. 从 E-R 图到建表脚本实体关系怎么落成 SQLE-R 图不是画着好看的它是建表脚本的施工图。很多人画完 E-R 图就直接去写 Python 代码了中间这一步跳过后面必然返工。正确的顺序是先确定实体和联系再把联系映射成表或外键最后写 DDL 脚本。2.1 图书管理系统的四个核心实体与联系图书管理系统的实体其实就四个图书、读者、借阅记录、管理员。图书和读者之间是多对多关系一本图书可以被多个读者在不同时间借阅一个读者也可以借多本书。这个多对多关系必须用一张独立的借阅记录表来承载不能试图在图书表或读者表里塞字段解决。管理员和图书之间是一对多一个管理员可以录入多本图书。管理员和借阅记录之间也是一对多一个管理员可以处理多笔借还操作。读者和借阅记录之间是一对多一个读者有多条借阅历史。这里有个容易翻车的地方很多人把「借阅」当成一个属性塞进图书表加个borrower_id字段就完事。这样做的问题是一本书借还多次后历史记录全丢了而且同一本书无法保留完整的流转轨迹。血泪经验是只要业务里出现「历史」「记录」「流水」这类词就必须单独建表。2.2 建表脚本字段类型、约束与索引的取舍下面这份 DDL 脚本是我一般会用的结构以 MySQL 为例SQLite 也能跑只需把AUTO_INCREMENT换成AUTOINCREMENT、ENGINEInnoDB去掉即可。-- 图书表核心是 ISBN 唯一约束和库存字段 CREATE TABLE book ( book_id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, -- ISBN 全局唯一防止重复录入 title VARCHAR(200) NOT NULL, author VARCHAR(100) NOT NULL, publisher VARCHAR(100), total_copies INT NOT NULL DEFAULT 1, -- 馆藏总数 available_copies INT NOT NULL DEFAULT 1, -- 可借数量借出减一归还加一 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CHECK (available_copies 0 AND available_copies total_copies) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 读者表用状态字段标记是否可借 CREATE TABLE reader ( reader_id INT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL UNIQUE, -- 借书证号 name VARCHAR(50) NOT NULL, phone VARCHAR(20), status TINYINT NOT NULL DEFAULT 1, -- 1 正常 0 冻结 max_borrow INT NOT NULL DEFAULT 5 -- 最大可借数不同读者可不同 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 借阅记录表多对多关系的载体也是事务的核心 CREATE TABLE borrow_record ( record_id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL, -- 应还日期 return_date DATE DEFAULT NULL, -- 为空表示未归还 status TINYINT NOT NULL DEFAULT 0, -- 0 借出 1 已还 2 逾期 FOREIGN KEY (book_id) REFERENCES book(book_id), FOREIGN KEY (reader_id) REFERENCES reader(reader_id), INDEX idx_reader_status (reader_id, status), INDEX idx_book_status (book_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段脚本里有几个参数值得单独说。available_copies上的CHECK约束是最后一道防线防止代码写错导致可借数变成负数。borrow_record表上的两个联合索引idx_reader_status和idx_book_status是为查询服务的查「某读者当前未还的书」和「某本书是否在借」都会走这两个索引。due_date单独存而不是每次用borrow_date加天数算是因为逾期规则可能变化存下来才有后悔药可吃。2.3 用 Python 执行建表脚本并验证结构脚本写好了别急着在客户端里手动跑。用 Python 执行一遍顺便验证表结构是否符合预期这样后面出问题好排查。import sqlite3 # 连接数据库文件不存在会自动创建 conn sqlite3.connect(library.db) cursor conn.cursor() # 读取建表脚本并执行executescript 支持多条语句 with open(schema.sql, r, encodingutf-8) as f: cursor.executescript(f.read()) # 验证表是否创建成功并打印字段信息 cursor.execute(SELECT name FROM sqlite_master WHERE typetable) tables cursor.fetchall() print(已创建的表:, tables) for table in [book, reader, borrow_record]: cursor.execute(fPRAGMA table_info({table})) print(f\n{table} 字段:) for col in cursor.fetchall(): print( , col[1], col[2]) # 字段名和类型 conn.commit() conn.close()executescript和execute的区别要记牢前者能一次执行多条以分号分隔的 SQL适合跑建表脚本后者一次只能执行一条。PRAGMA table_info是 SQLite 查看表结构的命令MySQL 里对应DESC 表名。这一步做完表结构就固化了后面所有代码都基于它写。3. 借书还书的核心逻辑事务、并发与库存扣减图书管理系统里真正有技术含量的就两件事借书和还书。其他都是围绕这两件事的查询和展示。这两件事必须放在事务里做否则并发一上来库存就乱了。3.1 借书操作为什么必须用事务包起来借一本书数据库层面要做三件事往borrow_record插一条记录、把book.available_copies减一、检查读者当前借阅数是否超限。这三步要么全成功要么全失败。如果插了记录但库存没减书就被「凭空借走」了如果库存减了但记录没插书就「消失」了。import sqlite3 from datetime import date, timedelta def borrow_book(reader_id, book_id, borrow_days30): conn sqlite3.connect(library.db) conn.isolation_level None # 关闭自动提交手动控制事务 cursor conn.cursor() try: cursor.execute(BEGIN) # 显式开启事务 # 1. 检查读者状态和当前借阅数 cursor.execute( SELECT status, max_borrow FROM reader WHERE reader_id?, (reader_id,) ) reader cursor.fetchone() if not reader or reader[0] ! 1: raise Exception(读者不存在或已被冻结) cursor.execute( SELECT COUNT(*) FROM borrow_record WHERE reader_id? AND status0, (reader_id,) ) current cursor.fetchone()[0] if current reader[1]: raise Exception(f已达最大借阅数 {reader[1]}) # 2. 检查库存用条件更新防止超借 cursor.execute( UPDATE book SET available_copies available_copies - 1 WHERE book_id? AND available_copies 0, (book_id,) ) if cursor.rowcount 0: raise Exception(库存不足借阅失败) # 3. 插入借阅记录 today date.today() due today timedelta(daysborrow_days) cursor.execute( INSERT INTO borrow_record(book_id, reader_id, borrow_date, due_date, status) VALUES (?, ?, ?, ?, 0), (book_id, reader_id, today.isoformat(), due.isoformat()) ) cursor.execute(COMMIT) return True except Exception as e: cursor.execute(ROLLBACK) print(借书失败:, e) return False finally: conn.close()这段代码的关键在UPDATE ... WHERE available_copies 0这一句。它把「检查库存」和「扣减库存」合并成了一条原子操作rowcount为 0 就说明库存不够。如果先SELECT查库存再UPDATE两个并发请求可能都查到库存为 1然后都去扣库存就成 -1 了。这是并发场景下最经典的踩坑点。isolation_level None是 SQLite 关闭自动提交的写法MySQL 里对应autocommitFalse。BEGIN和COMMIT/ROLLBACK成对出现异常路径必须回滚否则事务会一直挂着占锁。3.2 还书操作与逾期判断的边界处理还书比借书简单但逾期判断有边界。还书时要做三件事更新借阅记录的return_date和status、把库存加回去、判断是否逾期。def return_book(record_id): conn sqlite3.connect(library.db) conn.isolation_level None cursor conn.cursor() try: cursor.execute(BEGIN) # 查出记录确认未归还 cursor.execute( SELECT book_id, due_date, status FROM borrow_record WHERE record_id?, (record_id,) ) record cursor.fetchone() if not record: raise Exception(借阅记录不存在) if record[2] 1: raise Exception(该记录已归还请勿重复操作) book_id, due_date, _ record today date.today() # 逾期判断今天晚于应还日期即为逾期 new_status 2 if today date.fromisoformat(due_date) else 1 cursor.execute( UPDATE borrow_record SET return_date?, status? WHERE record_id?, (today.isoformat(), new_status, record_id) ) cursor.execute( UPDATE book SET available_copies available_copies 1 WHERE book_id? AND available_copies total_copies, (book_id,) ) if cursor.rowcount 0: raise Exception(库存异常归还失败) cursor.execute(COMMIT) return new_status # 1 正常归还 2 逾期归还 except Exception as e: cursor.execute(ROLLBACK) print(还书失败:, e) return -1 finally: conn.close()逾期判断用today due_date而不是因为应还日期当天归还仍然算正常。这个边界如果写错当天还书的人会被误判逾期属于典型的玄学 bug测试时不容易发现上线后投诉一堆。库存加回去时加了available_copies total_copies条件防止重复归还导致库存超过馆藏总数。3.3 用 SQL 聚合查询做借阅统计报表大作业答辩时老师很喜欢问「你这系统能出什么报表」。借阅统计是最容易加分的功能用一条聚合 SQL 就能出结果。-- 统计每本书的借阅次数和当前在借数量 SELECT b.title, COUNT(r.record_id) AS borrow_times, SUM(CASE WHEN r.status 0 THEN 1 ELSE 0 END) AS on_loan, SUM(CASE WHEN r.status 2 THEN 1 ELSE 0 END) AS overdue_times FROM book b LEFT JOIN borrow_record r ON b.book_id r.book_id GROUP BY b.book_id, b.title ORDER BY borrow_times DESC; -- 查询当前所有逾期未还的记录 SELECT r.card_no, r.name, b.title, br.due_date, julianday(now) - julianday(br.due_date) AS overdue_days FROM borrow_record br JOIN reader r ON br.reader_id r.reader_id JOIN book b ON br.book_id b.book_id WHERE br.status 0 AND br.return_date IS NULL AND date(now) br.due_date ORDER BY overdue_days DESC;第一条 SQL 用LEFT JOIN保证没被借过的书也能出现在结果里CASE WHEN做条件计数。第二条用julianday算逾期天数MySQL 里对应DATEDIFF。这些查询直接对应答辩时的「数据统计」需求提前写好能省很多事。4. 避坑与排查图书管理系统最容易翻车的五个地方这一章是我带过几届学生做这个题目后总结出来的高频问题每一条都按「现象 → 原因 → 解决」写照着排查能省下大量调试时间。4.1 中文乱码建库时字符集没设对现象是插入中文书名后查询出来全是问号或者乱码。原因通常是建库或建表时没指定字符集MySQL 默认可能是 latin1。解决办法是在建库语句里显式指定CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci建表时也带上DEFAULT CHARSETutf8mb4。Python 连接时也要确认编码SQLite 一般没这个问题MySQL 连接串里加charsetutf8mb4。已经建好的库可以用ALTER DATABASE和ALTER TABLE ... CONVERT TO补救但不如一开始就设对。4.2 外键约束导致删书失败现象是删除一本图书时报外键约束错误。原因是这本书在borrow_record里有借阅记录外键挡住了删除。这不是 bug是设计如此。正确的做法是不要物理删除有借阅历史的图书而是加一个is_deleted字段做逻辑删除或者先确认该书所有记录都已归还且允许归档。如果确实要删得先删关联记录但这会丢失历史一般不建议。答辩时被问到「为什么删不掉」能答出「为了保护借阅历史完整性」就是加分项。4.3 并发借书导致库存变负现象是压测或多人同时操作时available_copies出现负数。原因是先查后改两个请求都读到了相同的库存值。解决办法就是 3.1 里写的把检查和扣减合并成一条带条件的UPDATE用rowcount判断是否成功。如果用的是 MySQL还可以在UPDATE语句末尾加FOR UPDATE行锁但条件更新已经够用。这个坑在单机测试时几乎不会出现一旦并发就暴露属于典型的「测试环境好好的一上线就炸」。4.4 日期格式不统一导致查询失效现象是逾期查询查不到本该逾期的记录。原因往往是存日期时用了datetime对象查询时用字符串比较格式不一致。解决办法是统一用YYYY-MM-DD字符串存日期字段Python 里用date.isoformat()转换。SQLite 没有原生日期类型全靠字符串格式约定一旦混用2024/1/1和2024-01-01比较结果就全乱了。MySQL 的DATE类型会强制规范但传入非法格式会变成0000-00-00同样要小心。4.5 忘记提交事务导致数据丢失现象是代码跑完没报错但数据库里查不到新数据。原因是开了事务但忘了COMMIT或者异常路径没走ROLLBACK导致连接一直占着锁。解决办法是像 3.1 那样用try/except/finally把事务包严实COMMIT和ROLLBACK都有明确归属。调试时可以临时打开 SQL 日志看BEGIN之后到底有没有COMMIT。这个坑新手最容易踩因为程序不报错只是「数据没进去」排查方向容易跑偏。5. 让大作业多拿十分的两个进阶技巧基础功能跑通之后如果想让这个图书管理系统在答辩时更有说服力有两个方向可以加成本不高但效果明显。第一个是加一层简单的数据访问对象DAO把 SQL 从业务逻辑里抽出来。不用上 ORM 框架手写一个BookDAO类把borrow_book、return_book、search_book这些方法封装进去业务层只调方法不写 SQL。这样做的好处是答辩时老师问「你的 SQL 注入怎么防的」你可以直接指出所有查询都用了参数化占位符?而且集中在 DAO 层一眼就能看完。下面是一个最小 DAO 的写法class BookDAO: def __init__(self, conn): self.conn conn def search(self, keyword): # 参数化查询keyword 里的特殊字符不会被当成 SQL 执行 cursor self.conn.cursor() cursor.execute( SELECT book_id, title, author, available_copies FROM book WHERE title LIKE ? OR author LIKE ?, (f%{keyword}%, f%{keyword}%) ) return cursor.fetchall() def get_available(self, book_id): cursor self.conn.cursor() cursor.execute( SELECT available_copies FROM book WHERE book_id?, (book_id,) ) row cursor.fetchone() return row[0] if row else 0LIKE查询里的%是通配符拼在参数值里而不是 SQL 字符串里这样既实现了模糊匹配又不会引入注入风险。这是参数化查询和字符串拼接的本质区别答辩时能讲清楚这一点数据库安全这块的分基本就稳了。第二个技巧是给关键查询加执行计划分析。在 MySQL 里对借阅查询跑一句EXPLAIN看它有没有走索引。如果type列显示ALL说明全表扫描了这时候就该检查索引是不是没建对。我一般会把这个分析过程写进实验报告配上优化前后的对比老师一看就知道你是真跑过、真调过不是抄的。SQLite 里对应EXPLAIN QUERY PLAN输出格式不同但思路一样。最后一个习惯每次改完表结构一定重新跑一遍建表脚本别在旧库上手动ALTER。手动改的字段和脚本里的对不上过两天自己都忘了改过什么这就是没有后悔药的典型。脚本即真相库可以随时重建数据用测试数据填充就行。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑