☰
图书借阅管理系统数据库设计:从ER建模到SQL落地
2026/10/9 23:12:50 网站建设 项目流程

简介:面向大学数据库课程设计的《学校图书借阅管理系统》课程设计报告,覆盖数据库设计的完整流程:从需求分析、数据字典、数据流图到概要设计、E-R图与主要功能说明,逻辑清晰,章节完备。压缩包内仅1个doc文档,约4.16MB,Word格式便于直接阅读和修改,可作为课程设计模板或答辩资料。已有11178人浏览学习,是同类型设计报告中较热门的参考资源。内容围绕系统核心模块展开,包括欢迎界面、基于登录身份的权限管理、读者/管理员双入口、图书信息录入与修改、读者注册与信息维护、图书查询借阅归还、数据备份与恢复,以及密码设置与VFP环境恢复等;报告还附有主程序和各表单代码,能帮助理解VFP环境下的数据库操作与界面开发。整体结构清晰,适合需要完成图书借阅类数据库课程设计的读者参考借鉴。

1. 学校图书借阅管理系统的数据库设计:难点不在建表,在借阅关系怎么建模

学校图书借阅管理系统的数据库设计,最容易被低估的环节是借阅关系建模。见过不少课程设计和真实业务场景里的同类系统,图书表和读者表通常建得很规整,借阅记录表却随手设计几个字段,结果系统一开放就翻车:同一本书被两个读者同时借走、月底统计借阅量时数字怎么都对不上、想查某本实体书现在在谁手里只能人肉翻记录。这篇文章把整个设计过程拆开讲,从业务需求梳理、ER 建模开始,到关系模式设计、建表落地,再到借书、还书、续借、预约的完整 SQL 实现,最后把设计阶段最容易踩的坑逐个拆开。适合正在做课程设计、或者准备用数据库课程知识搭一个能真正跑起来的借阅管理后端的读者。

2. 需求梳理与实体建模:先画出借阅系统的 ER 图再动手写 SQL

2.1 从业务流倒推数据流:借书、还书、续借、预约背后各有哪些数据要落库

我一般不直接画 ER 图,而是先把业务流程列出来,再倒推每个动作要读写哪些数据。学校图书馆的核心场景拆开看,无非就是采编入库、借书、还书、续借、预约这几条线。每条线都对应明确的数据落点,列成一张表之后,需要建几张表基本就清楚了。

业务用例要落库的数据涉及的表
采编入库书目标信息、物理副本信息books、book_copies
借书借出记录、副本状态变更、库存扣减borrow_records、book_copies、books
还书归还时间、逾期罚款、副本状态回写borrow_records、fines、book_copies、books
续借应还日期延后、续借次数累加borrow_records
预约排队记录、保留状态、过期作废reservations、book_copies

从这张表能看出三个容易漏的点。第一,罚款不是一本书一个字段,而是一条独立记录,因为有逾期历史、缴纳状态、按天计费金额这些信息,塞在借阅记录里会让表变得臃肿而且没法追溯。第二,库存不是简单的「图书总量减当前借出量」,因为同一本书有多个物理副本,每个副本状态可能不同,这个在后面 2.2 节重点讲。第三,预约必须单独成表,因为预约涉及排队顺序、保留截止日期,而且预约状态和借阅状态是两套生命周期。

每个用例再往下拆一层,字段就出来了。借书时,系统要记录谁借的、借的是哪个副本、哪天借的、应还日期是哪天、当前操作的管理员是谁。还书时,除了更新归还日期,还要算逾期天数,生成罚款金额。续借时,要判断续借次数上限和是否有预约排队,只更新应还日期和续借次数。把这些字段汇总起来,实体清单就已经成形了,不需要凭感觉猜表结构。

2.2 核心实体与关系识别,以及一个典型的关系基数判断方法

实体一共七个,外加一个分类表。图书书目标(books)存的是「某一本书」的统一信息,比如书名、作者、ISBN、出版社;馆藏副本(book_copies)存的是「这一本书的某一个物理实体」,比如某本具体的书摆在哪个书架、条码是多少、当前状态是什么。读者、管理员、借阅记录、罚款记录、预约记录各自独立。分类表按需加,学校图书馆的图书量不算大,但分类查询和统计报表很常见,拆出来更规范。

这里必须展开讲「书目-副本」为什么要拆两张表。常见错误设计是在 books 表里直接放一个库存数字,用 quantity 表示总量、available 表示可借数。这个设计在演示 Demo 里能跑,但实际一用就露馅。比如同一本书有十本副本,其中一本破损、两本借出、一本被预约保留,单靠两个整数是描述不了这种状态的。再比如要查「某个条码的书现在在谁手里」,如果借阅记录只关联 book_id,根本定位不到具体实体。拆成两张表之后,每次借阅记录关联的是 copy_id 而不是 book_id,所有和实体书相关的问题都能回答。

实体间的关系基数,不要凭概念推,要从业务规则反推。规则是「一个馆藏副本同一时刻只能处于一条未归还的借阅记录里」,那 Copy 和 BorrowRecord 就是 1:N;规则是「一个读者可以同时借多本书」,那 Reader 和 BorrowRecord 就是 1:N;规则是「一本书可以被多个读者预约排队」,那 Book 和 Reservation 就是 1:N;规则是「一次借阅最多产生一条罚款」,那 BorrowRecord 和 Fine 是 1:0..1。每条规则都是系统里真实执行的约束,反推出来的关系基数才不会前后矛盾。

ER 图在这个阶段也顺手就能画了:实体画矩形,关系画菱形,基数和参与度标在连线上。画完之后做一次检查,确认每个关系都能对应到 2.1 里列的业务用例,没有「画了关系但不知道什么时候用」的悬空设计,就可以进入关系模式转换了。

3. 关系模式与建表落地:把 ER 图转成不冗余的 MySQL 表结构

3.1 从 ER 到关系模式的转换规则与规范化检查

ER 图转关系模式的规则是固定的:每个实体转一张表,实体的属性变成字段;1:N 关系把外键放在 N 端;M:N 关系需要额外建中间表。这个系统里最典型的是 Book 和 Copy 的 1:N,外键 book_id 放在了 book_copies 表,借阅记录通过 copy_id 间接关联到书目,整体关系链就是 readers → borrow_records → book_copies → books。这条链从头到尾都是 1:N,不需要中间表,设计负担小很多。

转完表之后要做规范化检查,重点看是否满足第三范式。举一个高频错误:books 表里直接存 category_name 分类名称而不是 category_id。表面上看查询少了一次 JOIN,但代价是要修改分类名时必须 UPDATE 全表里所有相关行,而且不同管理员录入时手写的分类名稍微不一致,「文学类」和「文学」就成了两条数据。把分类拆成独立表,books 只存 category_id,就消除了这个传递依赖。读者表的院系、班级字段同理,如果只是展示用途可以存文本,但要做按院系统计的报表,就必须拆。

有一个字段要单独讨论:books 表里的 available_copies。严格按第三范式,它是冗余字段,因为可以由「total_copies 减去所有未归还的借阅记录数」推导出来。但实际业务里这个值被高频读取,每次现算都要聚合 borrow_records,代价太高。常见做法是保留这个冗余字段,但给它立两条规矩:只能通过存储过程或者触发器维护,不允许应用层直接 UPDATE;每次借还操作后要自查它和明细记录是否一致。冗余本身不是问题,不受控的冗余才是问题。

3.2 核心表的字段设计与类型选择

字段类型的选择直接决定后面会不会踩坑。以下几类是这套系统里最容易选错的:

ISBN 用 VARCHAR(20) 而不是 BIGINT。ISBN 可能以 0 开头,纯数字类型会丢前导零;还有带连字符的展示形态,BIGINT 存不了;ISBN-13 已经超过 INT 范围,用 BIGINT 也只是勉强够。金额统一用 DECIMAL(10,2),FLOAT 和 DOUBLE 在累加计算时会产生精度误差,罚款金额虽然小,统计报表里差几分钱也解释不清。业务日期用 DATE,不用 DATETIME 或 TIMESTAMP,因为应还日期、归还日期这类字段只需要日历日,DATE 不涉及时区换算,能避开很多玄学问题。状态字段用 TINYINT 加 COMMENT,不用字符串,字符串枚举值一旦写错大小写,查询结果就缺一块,而且 TINYINT 扩展新状态时不用改表结构。

核心表的字段设计如下,建表脚本在 3.3 节可以直接复制。categories 表含 id、name、description;books 表含 id、isbn、title、author、publisher、publish_year、category_id、total_copies、available_copies、status;book_copies 表含 id、book_id、barcode、status、location;readers 表含 id、student_no、name、gender、phone、email、register_date、status、max_borrow_count;borrow_records 表含 id、reader_id、copy_id、borrow_date、due_date、return_date、renew_count、status、operator_id;fines 表含 id、borrow_record_id、reader_id、amount、fined_days、reason、is_paid、paid_date;reservations 表含 id、book_id、reader_id、reserve_date、expire_date、status。

3.3 建库建表 DDL:完整可复制的最小建表脚本

下面的建表脚本基于 MySQL 8.0 语法,字符集统一用 utf8mb4,引擎统一用 InnoDB。所有外键默认 RESTRICT,防止误删父表数据。状态字段加了 CHECK 约束,如果你的 MySQL 版本较老,检查约束可能只做语法解析而不强制执行,这种情况下需要应用层或者存储过程兜底校验。

CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE library_db; CREATE TABLE categories ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL, description VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_categories_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='图书分类表'; CREATE TABLE books ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL, title VARCHAR(200) NOT NULL, author VARCHAR(100) NOT NULL, publisher VARCHAR(100) DEFAULT NULL, publish_year SMALLINT UNSIGNED DEFAULT NULL, category_id INT UNSIGNED NOT NULL, total_copies INT UNSIGNED NOT NULL DEFAULT 0, available_copies INT UNSIGNED NOT NULL DEFAULT 0, status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1上架 0下架', PRIMARY KEY (id), UNIQUE KEY uk_books_isbn (isbn), KEY idx_books_category (category_id), CONSTRAINT fk_books_category FOREIGN KEY (category_id) REFERENCES categories (id), CONSTRAINT chk_books_available CHECK (available_copies <= total_copies) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='书目标表'; CREATE TABLE book_copies ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, book_id INT UNSIGNED NOT NULL, barcode VARCHAR(30) NOT NULL, status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0在架 1已借 2预约保留 3破损 4丢失', location VARCHAR(50) DEFAULT NULL COMMENT '馆藏位置', PRIMARY KEY (id), UNIQUE KEY uk_book_copies_barcode (barcode), KEY idx_book_copies_book (book_id), CONSTRAINT fk_book_copies_book FOREIGN KEY (book_id) REFERENCES books (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='馆藏副本表'; CREATE TABLE readers ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL, name VARCHAR(50) NOT NULL, gender TINYINT(1) DEFAULT NULL COMMENT '0女 1男', phone VARCHAR(20) DEFAULT NULL, email VARCHAR(100) DEFAULT NULL, register_date DATE NOT NULL, status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1正常 0停用', max_borrow_count TINYINT UNSIGNED NOT NULL DEFAULT 5, PRIMARY KEY (id), UNIQUE KEY uk_readers_student_no (student_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='读者表'; CREATE TABLE admins ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password_hash VARCHAR(255) NOT NULL, name VARCHAR(50) NOT NULL, role TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1管理员 2超级管理员', last_login_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_admins_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员表'; CREATE TABLE borrow_records ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, reader_id INT UNSIGNED NOT NULL, copy_id INT UNSIGNED NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL, return_date DATE DEFAULT NULL, renew_count TINYINT UNSIGNED NOT NULL DEFAULT 0, status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0借出中 1已还 2逾期已还 3丢失', operator_id INT UNSIGNED NOT NULL, PRIMARY KEY (id), KEY idx_borrow_records_reader_status (reader_id, status), KEY idx_borrow_records_copy (copy_id), KEY idx_borrow_records_due_date (due_date), CONSTRAINT fk_borrow_records_reader FOREIGN KEY (reader_id) REFERENCES readers (id), CONSTRAINT fk_borrow_records_copy FOREIGN KEY (copy_id) REFERENCES book_copies (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='借阅记录表'; CREATE TABLE fines ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, borrow_record_id INT UNSIGNED NOT NULL, reader_id INT UNSIGNED NOT NULL, amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, fined_days INT UNSIGNED NOT NULL DEFAULT 0, reason VARCHAR(100) NOT NULL DEFAULT 'overdue', is_paid TINYINT(1) NOT NULL DEFAULT 0, paid_date DATE DEFAULT NULL, PRIMARY KEY (id), KEY idx_fines_reader (reader_id), KEY idx_fines_record (borrow_record_id), CONSTRAINT fk_fines_record FOREIGN KEY (borrow_record_id) REFERENCES borrow_records (id), CONSTRAINT fk_fines_reader FOREIGN KEY (reader_id) REFERENCES readers (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='罚款记录表'; CREATE TABLE reservations ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, book_id INT UNSIGNED NOT NULL, reader_id INT UNSIGNED NOT NULL, reserve_date DATE NOT NULL, expire_date DATE NOT NULL, status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0排队 1保留 2已借 3取消 4过期', PRIMARY KEY (id), KEY idx_reservations_book_status (book_id, status), KEY idx_reservations_reader (reader_id), CONSTRAINT fk_reservations_book FOREIGN KEY (book_id) REFERENCES books (id), CONSTRAINT fk_reservations_reader FOREIGN KEY (reader_id) REFERENCES readers (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约记录表';

建表脚本里有几个设计决策需要说明。所有表统一用 utf8mb4 而不是 utf8,是因为馆藏数据里可能录入生僻字,分类描述和书名里也可能出现特殊符号,utf8mb4 覆盖全部 Unicode 字符,避免入库时报错。所有外键列都建了索引,这是 InnoDB 的硬性要求之一,如果外键列没有索引,MySQL 会自动补一个隐藏索引,与其让系统悄悄建,不如在 DDL 里写清楚。borrow_records 的复合索引 idx_borrow_records_reader_status 覆盖了「查某个读者当前借了哪些书」这个最高频的查询,单独建 reader_id 索引和 status 索引都不如这一个复合索引有效。fines 表和 reservations 表最常用的是按读者和按状态过滤,所以索引都往这两个方向建。

4. 借阅与归还主流程实现:用存储过程包住事务,用触发器守住约束

4.1 借书流程:库存检查、状态更新、记录插入如何串成原子操作

借书不是一个 INSERT 就能完成的动作,它横跨三张表:校验读者资格、校验副本状态、扣减库存、更新副本、插入借阅记录。任何一个环节失败,前面做的修改都要撤销。所以常见做法是把整个流程封装成存储过程,用事务包起来。

DELIMITER $$ CREATE PROCEDURE borrow_book( IN p_reader_id INT UNSIGNED, IN p_copy_id INT UNSIGNED, IN p_operator_id INT UNSIGNED ) BEGIN DECLARE v_reader_status TINYINT UNSIGNED; DECLARE v_max_borrow TINYINT UNSIGNED; DECLARE v_borrowed_count INT UNSIGNED; DECLARE v_copy_status TINYINT UNSIGNED; DECLARE v_book_id INT UNSIGNED; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 校验读者状态,FOR UPDATE 防止同时被多条请求处理 SELECT status, max_borrow_count INTO v_reader_status, v_max_borrow FROM readers WHERE id = p_reader_id FOR UPDATE; -- 注意:SELECT 没查到数据时变量为 NULL,必须先判断 NULL IF v_reader_status IS NULL OR v_reader_status != 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'reader is not active'; END IF; -- 统计该读者当前在借数量 SELECT COUNT(*) INTO v_borrowed_count FROM borrow_records WHERE reader_id = p_reader_id AND status = 0; IF v_borrowed_count >= v_max_borrow THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'borrow limit reached'; END IF; -- 校验副本状态并锁定该行 SELECT status, book_id INTO v_copy_status, v_book_id FROM book_copies WHERE id = p_copy_id FOR UPDATE; IF v_copy_status IS NULL OR v_copy_status != 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'copy is not available'; END IF; -- 条件更新:只在库存大于 0 时才扣减,避免并发把库存扣成负数 UPDATE books SET available_copies = available_copies - 1 WHERE id = v_book_id AND available_copies > 0; IF ROW_COUNT() = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'no available stock'; END IF; -- 副本置为已借出 UPDATE book_copies SET status = 1 WHERE id = p_copy_id; -- 插入借阅记录,借期 30 天 INSERT INTO borrow_records ( reader_id, copy_id, borrow_date, due_date, return_date, renew_count, status, operator_id ) VALUES ( p_reader_id, p_copy_id, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), NULL, 0, 0, p_operator_id ); COMMIT; END$$ DELIMITER ;

这段存储过程有四个关键点。第一,SELECT ... FOR UPDATE 把读者行和副本行锁住,两个会话同时借同一本副本时,第二个会等第一个提交,从源头避免并发竞争。第二,UPDATE books 用的是条件更新而不是「先查再改」,就算前面的锁漏掉了 books 表,这一句也能保证库存不会扣成负数。第三,每个 SELECT 之后都判断了 NULL,MySQL 的 INTO 语句查不到数据时不会抛错,而是把变量置 NULL,如果不对 NULL 做拦截,后面 IF 判断会因为 NULL 比较结果不是 TRUE 而直接跳过,这是新手最容易忽略的坑。第四,借期硬编码为 30 天,实际项目中最好把借期上限和续借次数做成配置表,通过参数传入,不要散落在存储过程里。

调用方式很简单,管理员在借书页面点确认按钮后,应用层执行CALL borrow_book(1001, 2001, 1),参数依次是读者 ID、副本 ID、操作管理员 ID。如果读者已停用、副本不在架、或者库存已清零,存储过程会通过 SIGNAL 抛出带 MESSAGE_TEXT 的异常,应用层捕获后直接展示给用户。

4.2 还书与续借:逾期天数计算和状态回写逻辑

还书流程是借书的镜像操作,多出来的部分是要计算逾期天数并生成罚款记录。如果还书时发现应还日期已经过了,状态不能简单置为「已归还」,要标记为「逾期已还」,同时按逾期天数和每日罚金标准计算罚款。

CREATE PROCEDURE return_book( IN p_record_id INT UNSIGNED, IN p_operator_id INT UNSIGNED ) BEGIN DECLARE v_reader_id INT UNSIGNED; DECLARE v_copy_id INT UNSIGNED; DECLARE v_book_id INT UNSIGNED; DECLARE v_status TINYINT UNSIGNED; DECLARE v_due_date DATE; DECLARE v_overdue_days INT; DECLARE v_fine_amount DECIMAL(10,2); DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; SELECT reader_id, copy_id, status, due_date INTO v_reader_id, v_copy_id, v_status, v_due_date FROM borrow_records WHERE id = p_record_id FOR UPDATE; IF v_status IS NULL OR v_status != 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'record is not in borrowing status'; END IF; SELECT book_id INTO v_book_id FROM book_copies WHERE id = v_copy_id; -- 逾期天数:当天归还 DATEDIFF 为 0,不算逾期 SET v_overdue_days = DATEDIFF(CURDATE(), v_due_date); IF v_overdue_days < 0 THEN SET v_overdue_days = 0; END IF; IF v_overdue_days > 0 THEN UPDATE borrow_records SET return_date = CURDATE(), status = 2, operator_id = p_operator_id WHERE id = p_record_id; SET v_fine_amount = v_overdue_days * 0.50; INSERT INTO fines ( borrow_record_id, reader_id, amount, fined_days, reason, is_paid, paid_date ) VALUES ( p_record_id, v_reader_id, v_fine_amount, v_overdue_days, 'overdue', 0, NULL ); ELSE UPDATE borrow_records SET return_date = CURDATE(), status = 1, operator_id = p_operator_id WHERE id = p_record_id; END IF; -- 副本状态回写 UPDATE book_copies SET status = 0 WHERE id = v_copy_id; -- 库存回补 UPDATE books SET available_copies = available_copies + 1 WHERE id = v_book_id; COMMIT; END$$

逾期天数用DATEDIFF(CURDATE(), v_due_date)算,这里有个边界:应还日期就是今天时 DATEDIFF 等于 0,不应该算逾期,所以代码里把所有负数都归 0。罚款金额按每天 0.50 元计算,这个数值在真实场景中应该做成配置,图书馆可能有不同的计费规则。还书后副本状态直接置回 0,对应「在架」,但如果这本书存在预约排队,就不能直接回 0,而应该置为 2「预约保留」,这一步在第 4.3 节展开。

续借流程相对简单,但有一个容易忽略的检查项:预约队列。如果有读者排着队等这本书,续借就不能通过,要把机

本文还有配套的精品资源,点击获取

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

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

立即咨询