PHP图书管理系统源码详解:从数据库设计到借还书业务闭环
2026/9/18 16:26:02 网站建设 项目流程

简介:一份基于PHP与MySQL的图书管理系统源代码,面向正在学习Web开发的初学者或需要完成课程设计的学生,帮助理解从用户登录、图书分类检索到借阅归还的完整业务流程。压缩包共112个文件,其中包含50个php后端脚本、11组frm/myd/myi数据库表文件,以及js/css前端资源和使用说明doc文档,整体大小仅624KB,便于直接部署与二次修改。已有6518人学习下载。项目覆盖用户管理、图书管理、分类管理、借阅与归还等核心模块,数据库表结构清晰,适合作为PHP+MySQL开发的实战范例。通过阅读源码可以掌握PDO数据库操作、会话状态跟踪、权限控制以及SQL注入防护等关键技能;程序自带的使用说明文档也能帮助你快速配置运行环境,理清代码调用关系,是一份兼顾教学与参考价值的完整项目源码。 做PHP图书管理系统这个项目,起因是一个社区阅览室的熟人找上门,说他们还在用Excel登记借还书,书一多就乱,逾期也完全靠人工翻表。需求听起来不复杂:把书管起来,借了能查、还了能销、逾期能提醒。但真的从零开始写这套PHP图书管理系统源代码时才发现,越是看着简单的业务,越容易在细节上翻车。前后折腾了两周,重写了两次数据库结构,今天把最终版本拆开来讲。

这套系统用到的技术非常朴素:原生PHP + MySQL + 少量JavaScript,没有引入任何框架。部署环境就是常见的小型服务器或者本地集成环境(phpStudy、XAMPP都行)。它适合三类人:一是正在做课程设计、毕业论文的计算机专业学生;二是想快速给单位、社区、班级配一个内部图书管理系统的小团队;三是想学习PHP后端开发基础,特别是借阅类业务逻辑的初学者。下面每一部分我都会先讲清楚设计思路,再给关键代码,保证你照着搭能跑起来,跑起来之后也知道每段代码在干嘛。

1. 需求梳理:图书管理系统的核心业务闭环

1.1 三类角色与四个操作场景

图书管理系统听起来像个"系统",实际落地的时候绕不开的其实就是几件事:管理员要登录;书要能录入、能改、能删;读者要能登记;借书和还书是整个系统的重头戏。

我在写给阅览室用的第一版时,犯过一个典型错误——把角色划分得太细,又是超级管理员又是普通管理员又是读者自助端,结果开发量翻倍,管理员根本用不过来。后来重新梳理,把系统收敛成一个管理员账号主导的闭环就够了。

这个闭环具体长这样:

  • 藏书入库:管理员录入新书的书名、作者、出版社、ISBN、分类、馆藏总量,系统自动把"可借数量"初始化为馆藏总量。
  • 读者登记:给每个读者分配一个借书证号(card_no),录入姓名、手机号,这个证号后续借书时要用。
  • 借书操作:检查这本书还有没有可借的副本,有则可借数量减一,同时在借阅记录表里插入一条借出记录,记录借出日期和应还日期。
  • 还书操作:找到对应借阅记录,写入实际归还日期,同时把书的可借数量加一。

这四个场景一旦理清,数据库表结构基本就能定下来了。那些花里胡哨的功能——图书封面上传、批量导入Excel、消息通知推送——在这个阶段统统不需要,先把主线跑通,比什么都强。

1.2 功能清单和边界——不要过度设计

很多初学者做管理系统容易陷入一个陷阱:功能越加越多,页面越做越花,最后连登录验证都还没写利索。我之前带过的实习生就是这样,图书列表页先做了个多条件组合筛选题,结果卡在SQL拼接上三天没出来。

我的建议是,第一版只做以下五个功能点:

  • 管理员登录与退出(session会话控制)
  • 图书列表 + 分页 + 简单关键词搜索
  • 图书新增、编辑、删除
  • 读者登记与列表
  • 借书与还书操作

其他像逾期罚款、预约借书、图书封面、数据统计这些,都放到第二版再说。这么做不是偷懒,而是为了控制风险:图书管理系统的核心价值在于"借还书的账目不能乱",一个事务没处理好,账面就会对不上,到时候排查起来比写十个功能都痛苦。

边界划清楚之后,另一个好处是方便测试。我在开发的时候曾经设了一个测试目标:用同一本书连续执行"借出-归还-借出-归还"十次,每次都要保证可借数量始终正确。这个小测试后来帮我抓到了两个隐藏bug,一个是还书时忘记判断借阅记录是否存在,另一个是事务没有回滚导致的数据错乱。这两点会在后面的代码章节详细展开。

2. 技术选型与运行环境:为什么还是PHP+MySQL

2.1 原生PHP和框架的取舍

先说结论:这套图书管理系统我选的是原生PHP + MySQL,没用Laravel、ThinkPHP这类框架。

原因主要有三个:第一,这个项目的核心是演示图书管理业务逻辑,用框架会把大量注意力分散到路由配置、模型关系、门面模式这些概念上,反而不利于理解本质;第二,很多课程设计和内部小项目要求的就是"源码能查、逻辑清晰",原生PHP的index.php打开就能看流程,对维护者极其友好;第三,部署门槛低,随便一个支持PHP的虚拟主机就能跑,不依赖composer安装一大堆依赖包。

当然,如果你的场景是长期迭代、多人协作、需要对接复杂权限体系的商业项目,那当然应该用框架。但就图书管理系统这个体量来说,原生PHP完全扛得住。另外一个折中方案是只用PDO数据库抽象层,避免直接使用已经废弃的mysqli拼接写法,这一点在后面的代码中会体现。

2.2 环境搭建中最容易翻车的两个点

运行环境我推荐直接用集成环境,Windows上用phpStudy,macOS上用MAMP,PHP版本选7.4或8.0以上都可以。我这里要特别强调两个容易翻车的细节。

第一是字符集设置。很多人在本地跑通之后,一上传到服务器就发现页面全是问号,绝大部分原因是数据库连接字符集没设成utf8mb4。phpStudy默认建的库可能是latin1或utf8,而你的表结构和页面都是utf8mb4,两者一碰就乱码。解决办法是在建库时指定字符集,PHP连接数据库时也要显式声明,代码统一使用utf8mb4,这块我后面会在完整源码里写清楚。

第二是PHP版本带来的函数差异。如果你用的是PHP 8,那么mysql_*系列函数绝对不能用,它们早就被删掉了。我见过太多老代码在PHP 8环境里直接白屏,就是因为还在用mysql_connect。这套源码统一使用PDO,PDO在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 ) ENGINE=InnoDB; 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) ) ENGINE=InnoDB; 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) ) ENGINE=InnoDB; 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) ) ENGINE=InnoDB;

我重点解释几个容易被忽略的设计点。第一,books表里同时维护total_count(馆藏总量)和available_count(当前可借数量),两个字段分开存是有意的:借出的时候只更新available_count,还书时也是只更新available_count,total_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里要把连接字符集显式设置为utf8mb4,PDO的DSN里加上charset=utf8mb4是关键,再加上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里的charset=utf8mb4、数据表本身的字符集(建表时用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源码。如果你拿到源码,我建议不要直接跑到服务器上就完事,而是照着这篇文章的数据库设计,自己动手在纸上画一遍借还流程图,再对照代码看每一步是怎么落地的——这样你才算真正把这个系统吃透。

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

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

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

立即咨询