简介:一款基于PHP开发的图书馆管理系统实例源码,用于图书借阅、归还、查询与统计管理,面向具备PHP基础的开发者与毕业设计学生。系统涵盖用户注册登录、图书管理、借阅归还、条件搜索、报表统计等核心模块,并通过实际代码展示MVC分层、PDO/MySQLi数据库操作、Session登录跟踪及防SQL注入等安全实践,适合作为理解Web应用前后端与数据库协作的参考项目。资源共164个文件,压缩包仅720KB。其中84个php文件承担业务逻辑与页面交互,13组frm/myd/myi为MySQL数据表的结构、数据和索引,另有gif界面截图、js/css前端文件以及csv/xls数据导入模板,便于按模块对照研究。目前已有132人学习。通过学习这套源码,可以掌握图书信息导入、借阅流程状态更新、搜索条件拼接、统计报表生成等典型功能的具体实现方式,同时从数据库设计、文件协作流程和错误处理中积累真实项目经验。
1. 别只把新翔图书馆当练习:这套源码包里藏着一个完整业务闭环
很多 PHP 开发者拿到这类“管理系统源码 zip”,习惯性先找.sql文件,没找到就删除。新翔图书馆管理系统恰恰是另一种典型:压缩包内是style.css、图书信息导入.csv和一堆.frm文件,没有现成建表脚本,却覆盖了图书馆日常里最关键的借阅流程。比起空转的 CRUD 演示,它更接近真实项目的状态:数据结构、用户权限、借还状态和时间戳,全部要靠读代码反向梳理。这套系统适合正在学 PHP 与 MySQL 协作的人,也适合需要一个能直接改造的后台管理骨架的开发者。下面从最容易劝退的.frm开始,把整个系统拆开。
2. 数据库层:从 frm 与 CSV 还原表结构,摸清 tb_bookinfo、tb_reader、tb_borrow 的字段血缘
多数源码包缺失 DDL,不代表表结构丢失。.frm是 MySQL 5.7 及更早版本保存表定义的文件,包含列、索引、字符集等信息,只是不存数据。新翔这套包里tb_bookinfo.frm、tb_reader.frm、tb_borrow.frm等文件,恰好把核心表结构都留在磁盘里。要让它跑起来,第一步不是写业务代码,而是把表定义还原出来。
2.1 用 mysqlfrm 把 .frm 还原成可执行 DDL
我一般先装mysql-utilities,然后用诊断模式直接读表结构:
mysqlfrm --diagnostic ./tb_bookinfo.frm--diagnostic参数让 mysqlfrm 不依赖正在运行的 MySQL 实例,直接扫描.frm文件并输出建表语句。输出里能看到字段名、类型、是否 NULL、默认值以及索引。如果没有 mysqlfrm,也可以用 MySQL Workbench 的 Migration Wizard,或者在一个 MySQL 5.7 容器中把.frm放到对应数据库目录下重启实例,再用SHOW CREATE TABLE导出。
有一点要提醒:.frm只给你表结构,样例数据仍然在图书信息导入.csv里。实际操作时我会先把 mysqlfrm 生成的 DDL 存成schema.sql,再决定后续导入策略。如果导出语句里有ENGINE=MyISAM,建议改成 InnoDB,因为借书、还书这类操作涉及库存扣减,需要事务和行锁保护。
2.2 图书信息导入.csv 的字段映射与导入参数
图书信息导入.csv通常是可以用 Excel 直接打开的标准 CSV。下面是一份常见的字段排列示例:isbn,book_name,author,publisher,type_id,pub_date,total_stock,shelf_no。对应导入语句如下:
LOAD DATA LOCAL INFILE '/data/library/图书信息导入.csv' INTO TABLE tb_bookinfo CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (isbn, book_name, author, publisher, type_id, pub_date, total_stock, shelf_no) SET stock = total_stock, available = total_stock;CHARACTER SET utf8mb4确保中文不会乱码;FIELDS TERMINATED BY ','只是普通分隔,如果某些字段里有逗号,必须配合OPTIONALLY ENCLOSED BY '"',它会让被双引号包住的内容整体识别成一个字段。LINES TERMINATED BY '\n'对应 Linux 环境下的换行,如果你在 Windows 导出过 CSV,需要改成'\r\n'。最后的SET子句可以在导入同时做字段加工,这里把总库存复制给了现有库存和可借库存,导入后不用再额外 UPDATE。
2.3 核心表关系与索引设计
从文件名就能看出职责边界:tb_bookinfo管书、tb_reader管读者、tb_borrow管借还、tb_booktype管分类,tb_purview管权限,tb_library可以映射到分馆或馆藏点,tb_borrowreg承担借还审计。把它们放在一张表里会更直观:
| 表名 | 核心字段 | 业务定位 |
|---|---|---|
| tb_bookinfo | book_id, isbn, book_name, author, type_id, stock, available | 图书书目与库存副本 |
| tb_reader | reader_id, login_name, password, role_id, owe_fine | 读者账号与欠款 |
| tb_borrow | borrow_id, book_id, reader_id, borrow_time, due_time, return_time | 借还流水主表 |
| tb_booktype | type_id, type_name, max_borrow_days | 图书分类与最大借期 |
| tb_purview | role_id, action_code, is_allowed | 角色-操作权限矩阵 |
| tb_library | library_id, name, address | 馆/分馆基础信息 |
| tb_borrowreg | reg_id, borrow_id, operate_time, operator | 借还登记轨迹 |
tb_borrow是整个系统的枢纽。查询“当前谁借了哪本书”时,需要同时关联读者和图书表:
SELECT r.reader_name, b.book_name, br.borrow_time, br.due_time FROM tb_borrow br JOIN tb_reader r ON r.reader_id = br.reader_id JOIN tb_bookinfo b ON b.book_id = br.book_id WHERE br.return_time IS NULL ORDER BY br.due_time;这里假设tb_borrow的return_time在未还时为 NULL,这条 SQL 可以拉出所有在借记录,并按应还时间排序,方便催还。关联查询一旦涉及tb_borrow的多个条件,就要考虑索引。实际项目中我至少会加上这几条:
ALTER TABLE tb_borrow ADD INDEX idx_borrow_book (book_id); ALTER TABLE tb_borrow ADD INDEX idx_borrow_reader (reader_id); ALTER TABLE tb_borrow ADD INDEX idx_borrow_due (due_time);book_id和reader_id是外键关联的检索入口,due_time会被大量用于“今天到期”“超期未还”这类查询。没有索引时,表数据超过几万条,这些查询很容易拖慢整站。
3. 借阅与归还模块:状态机从时间戳到 SQL 更新,避免超期漏算
借书和还书不是简单的INSERT和UPDATE。它要同时修改借阅流水、图书库存、读者状态,任何一步失败都会造成数据不一致。因此在写业务代码前,要先想清楚状态流转:可借、已借出、已归还、超期未还、产生滞纳金。下面用 PHP 的 PDO 预编译方式把借书流程写完整。
3.1 借书流程用 PDO 事务封装
try { $pdo->beginTransaction(); $stmt = $pdo->prepare( 'SELECT available FROM tb_bookinfo WHERE book_id = ? FOR UPDATE' ); $stmt->execute([$bookId]); $book = $stmt->fetch(); if (!$book || $book['available'] <= 0) { throw new RuntimeException('当前无可借副本'); } $pdo->prepare( 'UPDATE tb_bookinfo SET available = available - 1 WHERE book_id = ?' )->execute([$bookId]); $pdo->prepare( 'INSERT INTO tb_borrow (book_id, reader_id, borrow_time, due_time, borrow_admin, status) VALUES (?, ?, CURDATE(), DATE_ADD(CURDATE(), INTERVAL ? DAY), ?, 1)' )->execute([$bookId, $readerId, $borrowDays, $adminId]); $pdo->prepare( 'INSERT INTO tb_borrowreg (borrow_id, operate_time, operator, action) VALUES (LAST_INSERT_ID(), NOW(), ?, ?)' )->execute([$adminId, 'borrow']); $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); error_log('[borrow] ' . $e->getMessage()); echo '借书失败:' . htmlspecialchars($e->getMessage()); }代码里最关键的是SELECT ... FOR UPDATE。它会把对应图书行锁住,直到事务结束,这样两个管理员同时操作同一本书时,不会出现库存被扣成负数。CURDATE()由数据库生成日期,避免 PHP 与 MySQL 时区不一致。DATE_ADD(CURDATE(), INTERVAL ? DAY)中的$borrowDays可以来自tb_booktype表,不同分类借期不同。借阅主表插入后立即写tb_borrowreg,用LAST_INSERT_ID()拿到新主键,保留操作审计。
3.2 归还、逾期天数和滞纳金计算
归还时不能只把return_time更新掉,还要补算逾期费用,并且把库存加回去:
$pdo->beginTransaction(); $stmt = $pdo->prepare( 'SELECT * FROM tb_borrow WHERE borrow_id = ? AND return_time IS NULL FOR UPDATE' ); $stmt->execute([$borrowId]); $borrow = $stmt->fetch(); if ($borrow) { $now = new DateTimeImmutable(); $due = new DateTimeImmutable($borrow['due_time']); $diff = $now->diff($due); $late = $diff->invert ? $diff->days : 0; $fine = $late * 0.5; $pdo->prepare( 'UPDATE tb_borrow SET return_time = CURDATE(), fine = ?, return_admin = ? WHERE borrow_id = ?' )->execute([$fine, $adminId, $borrowId]); $pdo->prepare( 'UPDATE tb_bookinfo SET available = available + 1 WHERE book_id = ?' )->execute([$borrow['book_id']]); if ($fine > 0) { $pdo->prepare( 'UPDATE tb_reader SET owe_fine = owe_fine + ? WHERE reader_id = ?' )->execute([$fine, $borrow['reader_id']]); } $pdo->commit(); }计算逾期用到DateTimeImmutable::diff(),返回的DateInterval对象中invert等于 1 表示当前时间晚于应还时间,也就是说确实超期了。$diff->days是完整的相差天数。滞纳金单价 0.5 只是示例,真实系统应把“每日滞纳金单价”放进配置表或tb_booktype,避免写死在 PHP 代码里。
return_time IS NULL这个条件经常被忽略,它是防止同一笔借阅被重复归还的关键。再配合FOR UPDATE,能避免两个请求同时处理同一笔记录导致的重复加库存。
3.3 日期边界与状态字段的取舍
老代码里常见time()与date('Y-m-d')混用的情况,这会产生两个问题:一是 PHP 和 MySQL 的时区设置不一致,导致due_time和本地日期错位;二是time()到date()的转换在 23:59:59 附近容易让超期判断出现边界误差。更稳妥的做法是全部使用 MySQL 的DATE类型,PHP 侧只在展示时格式化。
另外,不要用“return_time为 NULL”作为判断在借的唯一依据,因为它表达不了“已超期但未还”的状态。建议给tb_borrow加一个status字段:1在借、2已归还、3超期未还。每天用定时任务扫描一次:
UPDATE tb_borrow SET status = 3 WHERE return_time IS NULL AND due_time < CURDATE() AND status = 1;这样做的好处是查询“超期未还”不再依赖每次临时用due_time < CURDATE()比较,报表阶段可以直接用状态字段过滤。
4. 用户认证与权限控制:把 session、cookie 和 tb_purview 串成一套可审计的登录态
图书馆系统通常有管理员、图书管理人员、普通读者三类角色。新翔源码里有tb_purview.frm,说明作者已经意识到权限不能靠硬编码解决。这一章从登录态开始,做到权限可配置、操作可追溯。
4.1 登录态设计:session 与 cookie 的分工
登录成功后只把用户 ID 放进$_SESSION,并为“记住我”单独签发一个随机令牌,避免把密码或用户 ID 直接写入 Cookie:
session_start(); if (password_verify($password, $hash)) { session_regenerate_id(true); $_SESSION['reader_id'] = $reader['reader_id']; $_SESSION['role_id'] = $reader['role_id']; if ($remember) { $token = bin2hex(random_bytes(32)); setcookie( 'remember_me', $token, time() + 86400 * 14, '/', '', true, true ); $pdo->prepare( 'UPDATE tb_reader SET remember_token = ? WHERE reader_id = ?' )->execute([hash('sha256', $token), $reader['reader_id']]); } }session_regenerate_id(true)在登录成功后更换 Session ID,能防止会话固定攻击。Cookie 里存的是random_bytes(32)生成的原始令牌,但数据库里只存它的 SHA-256 哈希。即使数据库被拖走,攻击者也无法把哈希还原成 Cookie 里的值。Cookie 的Secure和HttpOnly都设为true,后者能阻止 JavaScript 读取该 Cookie,降低 XSS 窃取会话的风险。
下次用户带着remember_meCookie 回来时,PHP 用相同哈希去查库,校验通过后重建 Session。
4.2 基于 tb_purview 的菜单级权限判断
把权限抽成“角色-操作”映射后,每个 PHP 入口只需要做一个判断:
function can($pdo, int $roleId, string $action): bool { $stmt = $pdo->prepare( 'SELECT COUNT(*) FROM tb_purview WHERE role_id = :role_id AND action_code = :action_code AND is_allowed = 1' ); $stmt->execute([ ':role_id' => $roleId, ':action_code' => $action, ]); return (int) $stmt->fetchColumn() > 0; }tb_purview表中的action_code是操作代码,例如:
| role_id | action_code | is_allowed |
|---|---|---|
| 1 | book.add | 1 |
| 1 | book.delete | 1 |
| 2 | book.add | 1 |
| 2 | book.delete | 0 |
| 3 | book.search | 1 |
管理员能删书,图书管理员只能录入不能删除,普通读者只能检索。业务代码里这样调用:
if (!can($pdo, $_SESSION['role_id'], 'borrow.return')) { http_response_code(403); exit('无权限执行归还操作'); }这种做法最大的好处是权限变更不需要改 PHP 代码,数据库里改一行就能生效。注意action_code必须统一命名规范,比如模块.动作,否则权限表很快会变成无人维护的死数据。
4.3 图书检索:把 LIKE 写对,再谈全文索引
很多管理系统查询图书还在用字符串拼接,这里至少要用预处理语句:
SELECT book_id, book_name, isbn, author, publish_date FROM tb_bookinfo WHERE book_name LIKE :kw OR author LIKE :kw OR isbn LIKE :kw ORDER BY create_time DESC LIMIT 20;PHP 侧绑定参数时,把%keyword%直接绑定到:kw:
$stmt = $pdo->prepare($sql); $stmt->execute([':kw' => '%' . $keyword . '%']);LIKE '%关键字%'无法走 B+ 树索引,因为通配符在开头。数据量小时还好,超过几十万条就会明显变慢。如果 MySQL 版本支持,可以给常用检索列加全文索引:
ALTER TABLE tb_bookinfo ADD FULLTEXT INDEX ft_book_search (book_name, author, isbn); SELECT book_id, book_name, author FROM tb_bookinfo WHERE MATCH(book_name, author, isbn) AGAINST('PHP 设计模式' IN NATURAL LANGUAGE MODE) LIMIT 20;全文索引对英文分词友好,MySQL 5.7 之后对中文也有一定支持,但仍建议把ngram全文解析器配置好。实际项目里我更倾向于先用LIKE保证搜得到,等真实体量上来后再切换全文索引或接入专门的搜索服务,不要在初期就把查询语句写到不可维护。
5. 报表与统计模块:把借阅流水变成运营指标的 SQL 和 PHP 输出技巧
借阅数据每天都在积累,真正有价值的不是单个查询,而是能反复使用的统计口径。这一章给出几个可以抄进项目的聚合查询,以及导出 CSV 时的注意点。
5.1 借阅量 TOP-N 与馆藏利用率
热门图书榜是一个很常见的需求,统计最近 90 天的借阅次数:
SELECT b.book_id, b.book_name, COUNT(br.borrow_id) AS borrow_count FROM tb_borrow br JOIN tb_bookinfo b ON b.book_id = br.book_id WHERE br.borrow_time >= DATE_SUB(CURDATE(), INTERVAL 90 DAY) GROUP BY b.book_id, b.book_name ORDER BY borrow_count DESC LIMIT 10;注意COUNT(br.borrow_id)只统计有真实借阅流水的记录,不会因为 JOIN 产生重复行。这里没有过滤status,因为“借出又归还”同样代表这本书被用过。如果把/库里的总可借副本数一起算出来,就得到馆藏利用率。类似“饱和度”的表达可以迁移到图书馆场景:利用率偏高说明副本不足,偏低则说明采购方向可能偏离读者需求。
5.2 PHP 流式导出 CSV 并兼容 Excel
统计结果在后台看不够,很多图书馆需要导出报表。用php://output直接写 HTTP 响应,避免在内存里组装一个大字符串:
header('Content-Type: text/csv; charset=UTF-8'); header('Content-Disposition: attachment; filename="borrow_report_' . date('Ymd') . '.csv"'); echo "\xEF\xBB\xBF"; $fp = fopen('php://output', 'w'); fputcsv($fp, ['图书编号', '书名', '借阅次数', '未归还数']); foreach ($rows as $row) { fputcsv($fp, [ $row['book_id'], $row['book_name'], $row['borrow_count'], $row['unreturned_count'], ]); } fclose($fp);echo "\xEF\xBB\xBF"输出 UTF-8 BOM,否则 Excel 直接打开 CSV 时中文列名会乱码。fputcsv默认用逗号分隔、双引号包裹含特殊字符的字段,比手动拼字符串可靠。如果导出数据量很大,可以分批查询,每批 5000 行,避免一次加载全部数据拖垮 PHP 内存。
5.3 表健康巡检与定时备份的一个便宜方案
最后补一个直接在 MySQL 里完成的小技巧:用事件调度器定期检查核心表,并把结果写入日志表,这样即使没有监控系统,也能发现索引损坏或表膨胀的端倪。
CREATE TABLE table_check_log ( table_name VARCHAR(64), check_result TEXT, check_time DATETIME ) DEFAULT CHARSET = utf8mb4; CREATE EVENT weekly_library_check ON SCHEDULE EVERY 1 WEEK STARTS CURRENT_TIMESTAMP DO INSERT INTO table_check_log SELECT 'tb_borrow', NULL, NOW() FROM information_schema.tables WHERE table_schema = 'library' AND table_name = 'tb_borrow';建议在真正执行CHECK TABLE t之前先做一次逻辑校验,例如对比tb_bookinfo.available是否等于stock - 当前借出数。这类一致性校验脚本放在报表生成前执行,能在问题影响用户前提前发现。
本文还有配套的精品资源,点击获取