
简介一款基于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再决定后续导入策略。如果导出语句里有ENGINEMyISAM建议改成 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_bookinfobook_id, isbn, book_name, author, type_id, stock, available图书书目与库存副本tb_readerreader_id, login_name, password, role_id, owe_fine读者账号与欠款tb_borrowborrow_id, book_id, reader_id, borrow_time, due_time, return_time借还流水主表tb_booktypetype_id, type_name, max_borrow_days图书分类与最大借期tb_purviewrole_id, action_code, is_allowed角色-操作权限矩阵tb_librarylibrary_id, name, address馆/分馆基础信息tb_borrowregreg_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 直接写入 Cookiesession_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_idaction_codeis_allowed1book.add11book.delete12book.add12book.delete03book.search1管理员能删书图书管理员只能录入不能删除普通读者只能检索。业务代码里这样调用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; charsetUTF-8); header(Content-Disposition: attachment; filenameborrow_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 - 当前借出数。这类一致性校验脚本放在报表生成前执行能在问题影响用户前提前发现。本文还有配套的精品资源点击获取