PHP图书管理系统源码详解:从数据库设计到借还书业务闭环

发布时间:2026/9/2 21:06:52
PHP图书管理系统源码详解:从数据库设计到借还书业务闭环 简介一份基于PHP与MySQL的图书管理系统源代码面向正在学习Web开发的初学者或需要完成课程设计的学生帮助理解从用户登录、图书分类检索到借阅归还的完整业务流程。压缩包共112个文件其中包含50个php后端脚本、11组frm/myd/myi数据库表文件以及js/css前端资源和使用说明doc文档整体大小仅624KB便于直接部署与二次修改。已有6518人学习下载。项目覆盖用户管理、图书管理、分类管理、借阅与归还等核心模块数据库表结构清晰适合作为PHPMySQL开发的实战范例。通过阅读源码可以掌握PDO数据库操作、会话状态跟踪、权限控制以及SQL注入防护等关键技能程序自带的使用说明文档也能帮助你快速配置运行环境理清代码调用关系是一份兼顾教学与参考价值的完整项目源码。 做PHP图书管理系统这个项目起因是一个社区阅览室的熟人找上门说他们还在用Excel登记借还书书一多就乱逾期也完全靠人工翻表。需求听起来不复杂把书管起来借了能查、还了能销、逾期能提醒。但真的从零开始写这套PHP图书管理系统源代码时才发现越是看着简单的业务越容易在细节上翻车。前后折腾了两周重写了两次数据库结构今天把最终版本拆开来讲。这套系统用到的技术非常朴素原生PHP MySQL 少量JavaScript没有引入任何框架。部署环境就是常见的小型服务器或者本地集成环境phpStudy、XAMPP都行。它适合三类人一是正在做课程设计、毕业论文的计算机专业学生二是想快速给单位、社区、班级配一个内部图书管理系统的小团队三是想学习PHP后端开发基础特别是借阅类业务逻辑的初学者。下面每一部分我都会先讲清楚设计思路再给关键代码保证你照着搭能跑起来跑起来之后也知道每段代码在干嘛。1. 需求梳理图书管理系统的核心业务闭环1.1 三类角色与四个操作场景图书管理系统听起来像个系统实际落地的时候绕不开的其实就是几件事管理员要登录书要能录入、能改、能删读者要能登记借书和还书是整个系统的重头戏。我在写给阅览室用的第一版时犯过一个典型错误——把角色划分得太细又是超级管理员又是普通管理员又是读者自助端结果开发量翻倍管理员根本用不过来。后来重新梳理把系统收敛成一个管理员账号主导的闭环就够了。这个闭环具体长这样藏书入库管理员录入新书的书名、作者、出版社、ISBN、分类、馆藏总量系统自动把可借数量初始化为馆藏总量。读者登记给每个读者分配一个借书证号card_no录入姓名、手机号这个证号后续借书时要用。借书操作检查这本书还有没有可借的副本有则可借数量减一同时在借阅记录表里插入一条借出记录记录借出日期和应还日期。还书操作找到对应借阅记录写入实际归还日期同时把书的可借数量加一。这四个场景一旦理清数据库表结构基本就能定下来了。那些花里胡哨的功能——图书封面上传、批量导入Excel、消息通知推送——在这个阶段统统不需要先把主线跑通比什么都强。1.2 功能清单和边界——不要过度设计很多初学者做管理系统容易陷入一个陷阱功能越加越多页面越做越花最后连登录验证都还没写利索。我之前带过的实习生就是这样图书列表页先做了个多条件组合筛选题结果卡在SQL拼接上三天没出来。我的建议是第一版只做以下五个功能点管理员登录与退出session会话控制图书列表 分页 简单关键词搜索图书新增、编辑、删除读者登记与列表借书与还书操作其他像逾期罚款、预约借书、图书封面、数据统计这些都放到第二版再说。这么做不是偷懒而是为了控制风险图书管理系统的核心价值在于借还书的账目不能乱一个事务没处理好账面就会对不上到时候排查起来比写十个功能都痛苦。边界划清楚之后另一个好处是方便测试。我在开发的时候曾经设了一个测试目标用同一本书连续执行借出-归还-借出-归还十次每次都要保证可借数量始终正确。这个小测试后来帮我抓到了两个隐藏bug一个是还书时忘记判断借阅记录是否存在另一个是事务没有回滚导致的数据错乱。这两点会在后面的代码章节详细展开。2. 技术选型与运行环境为什么还是PHPMySQL2.1 原生PHP和框架的取舍先说结论这套图书管理系统我选的是原生PHP MySQL没用Laravel、ThinkPHP这类框架。原因主要有三个第一这个项目的核心是演示图书管理业务逻辑用框架会把大量注意力分散到路由配置、模型关系、门面模式这些概念上反而不利于理解本质第二很多课程设计和内部小项目要求的就是源码能查、逻辑清晰原生PHP的index.php打开就能看流程对维护者极其友好第三部署门槛低随便一个支持PHP的虚拟主机就能跑不依赖composer安装一大堆依赖包。当然如果你的场景是长期迭代、多人协作、需要对接复杂权限体系的商业项目那当然应该用框架。但就图书管理系统这个体量来说原生PHP完全扛得住。另外一个折中方案是只用PDO数据库抽象层避免直接使用已经废弃的mysqli拼接写法这一点在后面的代码中会体现。2.2 环境搭建中最容易翻车的两个点运行环境我推荐直接用集成环境Windows上用phpStudymacOS上用MAMPPHP版本选7.4或8.0以上都可以。我这里要特别强调两个容易翻车的细节。第一是字符集设置。很多人在本地跑通之后一上传到服务器就发现页面全是问号绝大部分原因是数据库连接字符集没设成utf8mb4。phpStudy默认建的库可能是latin1或utf8而你的表结构和页面都是utf8mb4两者一碰就乱码。解决办法是在建库时指定字符集PHP连接数据库时也要显式声明代码统一使用utf8mb4这块我后面会在完整源码里写清楚。第二是PHP版本带来的函数差异。如果你用的是PHP 8那么mysql_*系列函数绝对不能用它们早就被删掉了。我见过太多老代码在PHP 8环境里直接白屏就是因为还在用mysql_connect。这套源码统一使用PDOPDO在PHP 7.4和8.x下都能稳定运行面向对象写法也更规范。环境准备完之后项目目录长这样就行library/ ├── config/ │ └── db.php // 数据库连接 ├── includes/ │ ├── auth.php // 登录验证 │ └── functions.php // 公共函数 ├── sql/ │ └── init.sql // 建表语句 ├── login.php // 登录页 ├── index.php // 图书列表 ├── book_add.php // 新增图书 ├── book_edit.php // 编辑图书 ├── book_delete.php // 删除图书 ├── reader.php // 读者管理 ├── borrow.php // 借书操作 └── return.php // 还书操作不要嫌目录简单对于这套系统保持一个页面一个入口反而是最直观的。团队协作或者后续提代码审查一眼就能定位问题。3. 数据库设计四张表串起整个借阅流程3.1 字段设计背后的考量数据库是整个系统最核心的部分表结构设计得好不好直接决定了借还书业务会不会出错。这套源码用了四张表users管理员、books图书、readers读者、borrow_records借阅记录。完整的建表语句如下CREATE DATABASE IF NOT EXISTS library DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci; USE library; CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE books ( id INT AUTO_INCREMENT PRIMARY KEY, isbn VARCHAR(20) DEFAULT NULL COMMENT ISBN编号, title VARCHAR(200) NOT NULL COMMENT 书名, author VARCHAR(100) DEFAULT NULL COMMENT 作者, publisher VARCHAR(100) DEFAULT NULL COMMENT 出版社, category VARCHAR(50) DEFAULT NULL COMMENT 分类, total_count INT NOT NULL DEFAULT 1 COMMENT 馆藏总量, available_count INT NOT NULL DEFAULT 1 COMMENT 当前可借数量, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_title (title), INDEX idx_category (category) ) ENGINEInnoDB; CREATE TABLE readers ( id INT AUTO_INCREMENT PRIMARY KEY, card_no VARCHAR(20) NOT NULL UNIQUE COMMENT 借书证号, name VARCHAR(50) NOT NULL COMMENT 读者姓名, phone VARCHAR(20) DEFAULT NULL COMMENT 联系电话, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_card_no (card_no) ) ENGINEInnoDB; CREATE TABLE borrow_records ( id INT AUTO_INCREMENT PRIMARY KEY, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATE NOT NULL COMMENT 借出日期, due_date DATE NOT NULL COMMENT 应还日期, return_date DATE DEFAULT NULL COMMENT 实际归还日期, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-借出中 1-已归还 2-逾期, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_book_id (book_id), INDEX idx_reader_id (reader_id), INDEX idx_status (status) ) ENGINEInnoDB;我重点解释几个容易被忽略的设计点。第一books表里同时维护total_count馆藏总量和available_count当前可借数量两个字段分开存是有意的借出的时候只更新available_count还书时也是只更新available_counttotal_count永远不变这样统计藏书总量时不用去算借出的记录。第二borrow_records表里的status字段不是必需的因为可以通过return_date是否为空来判断是否已还但保留一个冗余状态字段能显著简化查询尤其是当前有哪些借出中的记录这种高频查询直接WHERE status 0就行不需要再判断return_date IS NULL。3.2 可借数量为什么要单独维护有些同学可能会想为什么不直接统计borrow_records里status 0的记录数然后拿total_count减一下得到可借数量逻辑上确实可以但性能上不划算而且在并发场景下容易出现统计和实际不一致。举个例子一本书有3个副本当前有2条借出中的记录算下来可借数量是1。这个算法在数据量小的时候没问题但如果一个人同时打开两个浏览器标签页同时点了两次借书两条都通过了可借数量大于0的判断各自执行插入最终库存就变成-1了账面直接乱掉。所以我在源码里借书操作的核心逻辑是先执行UPDATE再判断受影响行数而不是先SELECT判断再UPDATE。这个思路来源于乐观锁的实践——把检查和扣减合并成一个原子操作从根上避免并发问题。// 借书核心代码片段 $stmt $pdo-prepare(UPDATE books SET available_count available_count - 1 WHERE id ? AND available_count 0); $stmt-execute([$bookId]); if ($stmt-rowCount() 0) { // 说明没有可借副本 throw new Exception(这本书暂时没有可借的副本); }这段代码的巧妙之处在于UPDATE语句本身带了available_count 0的条件MySQL在执行行锁的时候会检查条件不符合就直接影响0行。这样就算100个人同时借同一本书也只有前3个人能成功后面的全部被拦下。这是我踩过并发坑之后总结出的最优写法。4. 核心源代码拆解从登录到借还书4.1 登录认证与会话控制登录功能是所有管理系统的入口我用的方案是PHP原生Session。代码不复杂但有一个地方必须注意存储密码不要用MD5直接用password_hash()和password_verify()。登录验证的代码长这样?php session_start(); require_once __DIR__ . /config/db.php; if ($_SERVER[REQUEST_METHOD] POST) { $username trim($_POST[username] ?? ); $password $_POST[password] ?? ; if ($username || $password ) { $error 用户名和密码不能为空; } else { $stmt $pdo-prepare(SELECT * FROM users WHERE username ? LIMIT 1); $stmt-execute([$username]); $admin $stmt-fetch(); if ($admin password_verify($password, $admin[password])) { session_regenerate_id(true); $_SESSION[admin_id] $admin[id]; $_SESSION[admin_name] $admin[username]; header(Location: index.php); exit; } $error 用户名或密码错误; } }说说我特别想提醒的两点。第一登录成功之后调用session_regenerate_id(true)是防止会话固定的常用手段因为登录前和登录后的会话ID不同攻击者无法通过预置会话ID的方式来模拟登录态。第二config/db.php里要把连接字符集显式设置为utf8mb4PDO的DSN里加上charsetutf8mb4是关键再加上PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION任何SQL错误都会抛出异常排查问题的时候能少走很多弯路。用户表里还需要预置一个管理员账号init.sql里只建了表结构实际部署时需要用脚本插入一条密码由password_hash生成的记录。我一般会在部署说明里写一个单独的工具脚本create_admin.php执行完就删除避免管理员密码长期暴露在源码里。4.2 图书列表与分页搜索图书列表页是使用频率最高的页面我采用了分页 关键词搜索的组合。分页在数据量大的时候是必须的不然几百本书堆在一个页面浏览器都要卡顿。分页功能最核心的代码是计算总页数和偏移量?php $page isset($_GET[page]) ? max(1, (int)$_GET[page]) : 1; $pageSize 10; $offset ($page - 1) * $pageSize; $keyword trim($_GET[keyword] ?? ); if ($keyword ! ) { $countStmt $pdo-prepare(SELECT COUNT(*) FROM books WHERE title LIKE ? OR author LIKE ?); $countStmt-execute([%$keyword%, %$keyword%]); $total (int)$countStmt-fetchColumn(); $stmt $pdo-prepare(SELECT * FROM books WHERE title LIKE ? OR author LIKE ? ORDER BY id DESC LIMIT ? OFFSET ?); $stmt-bindValue(1, %$keyword%, PDO::PARAM_STR); $stmt-bindValue(2, %$keyword%, PDO::PARAM_STR); $stmt-bindValue(3, $pageSize, PDO::PARAM_INT); $stmt-bindValue(4, $offset, PDO::PARAM_INT); $stmt-execute(); } else { $total (int)$pdo-query(SELECT COUNT(*) FROM books)-fetchColumn(); $stmt $pdo-query(SELECT * FROM books ORDER BY id DESC LIMIT $pageSize OFFSET $offset); } $books $stmt-fetchAll();这里有个细节要特别注意PDO的execute()如果直接传参数数组所有值都会被当成字符串处理。LIMIT 10 OFFSET 20里的10和20传给MySQL时会被转成10和20虽然一般能隐式转换但为了稳妥在LIMIT和OFFSET上最好用bindValue明确指定PDO::PARAM_INT。这也是我从一次线上教训里学到的——当时用的老写法在某些MySQL配置下会报语法错误整页白屏。列表页的HTML渲染部分每一本图书的可借数量我用了一个醒目的颜色标识可借数量为0时显示已借完这样管理员扫一眼就能知道哪些书需要补货。这种做法不增加任何技术成本但对实际使用体验的提升非常明显。4.3 借书和还书的完整业务闭环借书是门槛最高的操作因为它同时涉及三样东西的变更图书的可借数量、读者的借阅记录、以及整个操作的事务一致性。我用PDO事务把借书过程封装成一个方法?php function borrowBook(PDO $pdo, int $bookId, int $readerId): void { $pdo-beginTransaction(); try { // 1. 扣减可借数量原子操作 $stmt $pdo-prepare(UPDATE books SET available_count available_count - 1 WHERE id ? AND available_count 0); $stmt-execute([$bookId]); if ($stmt-rowCount() 0) { throw new RuntimeException(这本书暂时没有可借的副本); } // 2. 检查读者是否存在 $check $pdo-prepare(SELECT id FROM readers WHERE id ?); $check-execute([$readerId]); if (!$check-fetch()) { throw new RuntimeException(读者不存在请先登记读者信息); } // 3. 插入借阅记录应还日期默认为30天后 $stmt $pdo-prepare( INSERT INTO borrow_records (book_id, reader_id, borrow_date, due_date, status) VALUES (?, ?, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 0) ); $stmt-execute([$bookId, $readerId]); $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); throw $e; } }这里面最关键的是beginTransaction和rollBack的组合。因为在借书过程中如果扣减库存成功但插入借阅记录失败了比如字段长度超限、外键约束错误那么账面就会出现库存减了但没借阅记录的严重问题。有了事务包裹要么全部成功要么全部回滚不会留下中间状态。还书的逻辑方向相反但同样需要事务?php function returnBook(PDO $pdo, int $recordId): void { $pdo-beginTransaction(); try { // 1. 查找借阅记录并且必须未归还 $stmt $pdo-prepare(SELECT book_id, status FROM borrow_records WHERE id ? FOR UPDATE); $stmt-execute([$recordId]); $record $stmt-fetch(); if (!$record || (int)$record[status] ! 0) { throw new RuntimeException(借阅记录不存在或已经归还); } // 2. 更新借阅记录为已归还 $stmt $pdo-prepare(UPDATE borrow_records SET return_date CURDATE(), status 1 WHERE id ?); $stmt-execute([$recordId]); // 3. 增加图书可借数量 $stmt $pdo-prepare(UPDATE books SET available_count available_count 1 WHERE id ?); $stmt-execute([$record[book_id]]); $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); throw $e; } }这里用到了SELECT ... FOR UPDATE它会把这条借阅记录锁住防止两个人同时归还同一本书导致重复加库存。这些并发细节在单机测试时可能完全感觉不到但一旦系统上线、多人同时操作就会暴露出来所以我习惯在写代码时就把这些防护加上。5. 源码里的安全与兼容性细节5.1 防SQL注入和XSS宁可过度也不缺席图书管理系统这类内网小系统很多人觉得反正没多少人用安全不重要。但我的原则是不管系统多小基本的防御姿势必须做对因为修复一条SQL注入漏洞可能比写整个系统还痛苦。整套源码统一使用PDO预处理语句所有用户输入都通过占位符传参不拼接SQL。这样可以挡住绝大部分SQL注入。除此之外我还做了一个容易被忽略的事所有输出到HTML的字段都经过htmlspecialchars()处理。?php function h(?string $value): string { return htmlspecialchars($value ?? , ENT_QUOTES, UTF-8); }这个h()函数在每一处输出书名、作者、读者姓名的地方都调用。为什么因为如果某本书的书名是 这种内容管理员在列表页看到时浏览器会把它当成HTML执行。这就是存储型XSS危害比SQL注入更隐蔽。图书管理系统里书名、作者这些字段都是管理员手动录入的虽然风险相对低但一套通用安全的输出函数成本几乎为零没理由不做。管理员的密码存储也顺手说一下。初始化时用password_hash($password, PASSWORD_DEFAULT)生成哈希验证时用password_verify()。这套机制会自动加盐并且后续PHP升级时算法也会自动演进比MD5加盐那种自己造轮子的方案安全得多。5.2 中文乱码、时间格式、文件组织这些磨人细节除了安全图书管理系统还有几个非常磨人的细节问题每一个都能让人折腾半天。中文乱码是出现频率最高的。出问题的时候不要只改一个地方需要同时检查三层数据库连接DSN里的charsetutf8mb4、数据表本身的字符集建表时用DEFAULT CHARSET utf8mb4、HTML页面的charset声明。这三层只要有一层不一致就有可能出现乱码。还有一个容易忽略的点PHP文件本身保存的编码也必须是UTF-8如果编辑器把文件存成了GBK即使数据库和页面都正确中文字符串也会乱。时间格式方面我的建议是数据库里只存DATE或DATETIME展示时再格式化。借阅记录里的borrow_date用DATE就够了因为借书不用精确到时分秒。如果你需要统计每天借出了多少本书DATE类型可以直接GROUP BY非常方便。文件组织上config/db.php里我做了统一配置把数据库地址、用户名、密码、库名都放在数组里方便部署时修改。同时把错误显示开关做成一个常量开发环境打开生产环境关闭避免SQL错误信息直接暴露给用户?php // config/db.php const DB_HOST 127.0.0.1; const DB_NAME library; const DB_USER root; const DB_PASS ; const DB_CHARSET utf8mb4; // 开发环境显示错误生产环境请改为 false const DEBUG true; if (DEBUG) { error_reporting(E_ALL); ini_set(display_errors, 1); } else { error_reporting(0); ini_set(display_errors, 0); } $dsn mysql:host . DB_HOST . ;dbname . DB_NAME . ;charset . DB_CHARSET; $options [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, ]; try { $pdo new PDO($dsn, DB_USER, DB_PASS, $options); } catch (PDOException $e) { exit(数据库连接失败请检查配置文件); }PDO::ATTR_EMULATE_PREPARES false这个设置值得单独提一下它让PDO使用MySQL原生的预处理能力而不是在PHP端模拟。好处是参数类型区分更严格、性能更好更重要的是能让LIMIT这类语句的参数绑定行为更规范。如果你在分页时遇到了奇怪的语法错误优先检查这个选项。6. 部署上线前一定要做的三件事代码写完只是第一步真正能交付给阅览室用还需要过一道上线安检。我在这套系统中反复测试了三个场景也建议你部署前按照这个清单走一遍。第一用同一本书测试连续借还20次确认available_count始终回到初始值。这个测试能排查出事务遗漏、库存重复增减的问题。如果中途数字不对优先检查是不是有某条SQL没包含在事务里或者某处提前return跳过了后续代码。第二测试两个浏览器同时借同一本书的并发场景。用Chrome和Edge分别打开借书界面快速同时提交观察是否会出现超借。正确的结果是只有一本书可借时第一次借出后第二次会被拦截。这个测试能验证UPDATE条件判断是否生效。第三检查所有页面的字符集三件套是否一致。随便找一本书名带生僻字的书录入后再编辑一次看是否出现乱码再用含引号和尖括号的书名测试输出确认页面没有被HTML解析。这些都是最容易被忽略、又最容易在验收时被挑出来的问题。做完这三件事系统基本就可以上线了。我把完整的源代码打包整理过包含init.sql初始化脚本、readme部署文档和上面的完整PHP源码。如果你拿到源码我建议不要直接跑到服务器上就完事而是照着这篇文章的数据库设计自己动手在纸上画一遍借还流程图再对照代码看每一步是怎么落地的——这样你才算真正把这个系统吃透。本文还有配套的精品资源点击获取

相关新闻