简介:图书馆系统与图书推荐系统的完整实现代码与数据库脚本,面向需要完成同类课程设计或入门Java Web开发的读者,集成了借阅、归还、查询及基于用户历史的个性化推荐等核心模块。压缩包共89个文件,其中11个java为可读源码、66个class为编译产物,另含3个sql数据库脚本、2个jar驱动包及jpg界面预览图,整体5.02MB。资源覆盖图书表、用户表、借阅记录表设计,以及权限控制、库存管理和推荐算法在项目中的具体落地,附带项目配置与工程结构,可直接导入IDE运行,也可作为二次开发基础。已有312人学习下载,适合需要对照完整案例掌握图书管理系统开发流程、理解推荐系统实现思路的读者。
1. 从压缩包里那个双JDBC驱动说起
解压library_System.rar后,看到lib目录下并排躺着mysql-connector-java-8.0.19.jar与ojdbc6-11.2.0.3.jar两个驱动文件时,基本可以断定这套代码不只是写个图书增删改查那么简单。它意味着同一个系统需要同时适配MySQL和Oracle两套数据库方言,而这种设计通常出现在课程设计、毕业设计或企业预研项目中,为的是演示图书管理系统从单库向多库迁移时的兼容性。除了驱动,压缩包根目录还有library_user.sql、library_book.sql、library_borrower.sql三个建表脚本,src与bin目录对应Eclipse工程的源码与编译产物,0.jpg和1.jpg是界面素材。这套资源覆盖了一张完整的图书管理系统应该具备的要素:基础CRUD、借还流程、查询,以及基于借阅历史的图书推荐。适合正在做Java课程设计、想弄懂JDBC编程和推荐逻辑的开发者,也适合需要一套多数据库兼容骨架作为改造起点的从业者。
2. 三个SQL脚本、三张表:借阅与推荐的数据闭环
2.1 图书表的设计:库存字段为什么要拆两个
在打开的library_book.sql里,核心的图书表是这样定义的(字段做了一致化处理):
CREATE TABLE book ( book_id INT PRIMARY KEY AUTO_INCREMENT, book_name VARCHAR(100) NOT NULL, author VARCHAR(50) DEFAULT '佚名', publisher VARCHAR(80), isbn VARCHAR(20) UNIQUE, category VARCHAR(30), total_stock INT DEFAULT 0, current_stock INT DEFAULT 0, book_desc TEXT, INDEX idx_book_name (book_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段建表语句里的total_stock与current_stock是我在同类项目里最关注的两个字段。total_stock表示入库总量,current_stock表示当前可借数量;每次借书时current_stock减一,每次还书时current_stock加一。很多第一次做图书管理系统的同学只留一个stock字段,还书时直接加回去,这样也能跑通,但一旦要统计“哪本书借阅量最高”,就需要从借阅记录里逐条count,数据量过千后性能就会明显下降。这里把总库存和可借库存拆开,再用total_stock - current_stock去反推当前在外流通量,本质上是用冗余字段换查询速度,在几千册图书的规模下是划算的。
| 字段 | 类型 | 作用 |
|---|---|---|
| book_id | INT | 主键,自增 |
| book_name | VARCHAR(100) | 书名,用于检索 |
| isbn | VARCHAR(20) | 唯一约束,防重复录入 |
| total_stock | INT | 总入库量,不随借还变化 |
| current_stock | INT | 当前可借量,借减还加 |
| category | VARCHAR(30) | 图书分类,推荐系统候选池筛选依据 |
2.2 用户表与权限字段
library_user.sql里用户表定义类似:
CREATE TABLE `user` ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(64) NOT NULL, role TINYINT DEFAULT 0, borrow_count INT DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;role字段在这个项目里承担了权限控制:0代表普通用户,1代表管理员。登录后主界面的借阅按钮是否可用,不是靠前端隐藏,而是在后端每次执行借书操作时用session里的role做拦截,这一点会在第三章的代码中体现。password字段存放的是MD5散列后的32位字符串,不是明文,这样即使数据库文件被拖走,拿到手也只是散列值。
2.3 借阅记录表:推荐系统真正依赖的数据源
CREATE TABLE borrow_record ( record_id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, borrow_time DATETIME DEFAULT CURRENT_TIMESTAMP, return_time DATETIME DEFAULT NULL, status TINYINT DEFAULT 0, fine DECIMAL(6,2) DEFAULT 0.00, CONSTRAINT fk_borrow_user FOREIGN KEY (user_id) REFERENCES `user`(user_id), CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book(book_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;status字段用0表示借出中、1表示已归还,归还时同时写return_time和status。fine字段记录逾期滞纳金,由前端按归还日期计算后更新。这三个脚本之间通过外键形成了完整的引用链:borrow_record每插入一条记录,都要保证user_id和book_id真实存在;而推荐逻辑正是从这张表里拉取“哪些用户借过哪些书”,再进一步聚合出相似用户。
提示:如果在Eclipse里导入项目后数据库连接报错,先检查三个sql脚本是否已按顺序执行。顺序应当是library_user.sql、library_book.sql、library_borrower.sql,因为借阅记录表依赖前两张表的主键。
3. JDBC编程实战:登录鉴权、图书检索、借阅事务
3.1 连接获取:不写连接池也要统一入口
这个项目没有引入连接池依赖,而是用了一个典型的DBUtil类,src目录下对应包名类似com.library.database。常见写法是:
public class DBUtil { private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/library_System" + "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }Class.forName在这里做的事是向DriverManager注册驱动,registerDriver内部会校验驱动类的字节码版本,所以MySQL 8.0.19要求JRE必须高于1.8,如果你的Eclipse运行环境是JDK 1.7,加载驱动时会直接抛UnsupportedClassVersionError。URL中的serverTimezone=Asia/Shanghai是8.x版本必填参数,缺了它会报The server time zone value unrecognized,这个报错很容易误导排查方向。这里每次都通过DriverManager拿新连接,适合单用户压测和课程作业;部署到真实环境时,建议用HikariCP替换,但对本项目的推荐模块来说,连接获取方式的影响可以忽略。
| JDBC操作 | 关键参数 | 踩坑点 |
|---|---|---|
| 驱动加载 | Class.forName | 8.x必须写com.mysql.cj.jdbc.Driver |
| 连接URL | serverTimezone | 不设置会报时区错误 |
| 查询 | PreparedStatement | 不要用Statement拼SQL |
| 事务 | setAutoCommit(false) | 忘记commit会导致数据丢失 |
3.2 登录校验:参数化查询与MD5比对
登录方法不能直接拼接SQL,这是JDBC课设里最常见的代码评审扣分点。项目源码里的登录方法核心思路如下:
public User login(String username, String password) throws SQLException { String md5pwd = MD5Util.md5(password); String sql = "SELECT user_id, username, role FROM `user` WHERE username=? AND password=?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, md5pwd); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return new User(rs.getInt("user_id"), rs.getString("username"), rs.getInt("role")); } } } return null; }这里有两个细节值得说明:第一,数据库存储的是MD5值,用户在界面输入的原文先经过MD5Util.md5()做散列,再与库里的值比对,防止数据库泄露后密码被直接读取;第二,所有用户传入值都用setString绑定参数,而不是用加号拼进SQL里,这样即使用户输入引号或or 1=1,也会被当作普通字符串处理,防止SQL注入。查询结果只取user_id、username、role三个字段,password不参与select,减少Session中的敏感数据暴露面。
3.3 借还书:事务与库存校验
借书操作是一个跨表操作:先检查current_stock大于0,再往borrow_record插入记录,最后更新book表的current_stock。这三步必须在一个事务里完成。代码示意:
public boolean borrowBook(Connection conn, int userId, int bookId) throws SQLException { String checkSql = "SELECT current_stock FROM book WHERE book_id=? FOR UPDATE"; String insertSql = "INSERT INTO borrow_record(user_id, book_id) VALUES(?,?)"; String updateSql = "UPDATE book SET current_stock=current_stock-1 WHERE book_id=?"; try (PreparedStatement checkPs = conn.prepareStatement(checkSql)) { checkPs.setInt(1, bookId); try (ResultSet rs = checkPs.executeQuery()) { if (!rs.next() || rs.getInt("current_stock") <= 0) { return false; } } try (PreparedStatement insertPs = conn.prepareStatement(insertSql); PreparedStatement updatePs = conn.prepareStatement(updateSql)) { insertPs.setInt(1, userId); insertPs.setInt(2, bookId); updatePs.setInt(1, bookId); insertPs.executeUpdate(); updatePs.executeUpdate(); } return true; } }select ... FOR UPDATE 在事务内给对应图书记录加了行级锁,避免两个用户同时借最后一本书时都读到current_stock=1而双双通过校验。这个手法在教材里讲得不多,但真实项目中并发借阅场景一定会遇到。调用侧要这样包事务:
try (Connection conn = DBUtil.getConnection()) { conn.setAutoCommit(false); boolean ok = borrowBook(conn, userId, bookId); if (ok) { conn.commit(); } else { conn.rollback(); } }注意,事务隔离级别在MySQL默认是REPEATABLE_READ,配合FOR UPDATE锁能够覆盖绝大多数并发借书场景。如果把隔离级别调到READ_UNCOMMITTED,会出现脏读,因此保持默认即可。
4. 图书推荐:基于借阅历史的协同过滤简化实现
4.1 推荐策略选择
许多图书馆课程设计的推荐功能只是“按分类随机推荐”,而这个项目把推荐落到了borrow_record数据上,用到的核心思想是“借过同一本书的人,还可能借彼此借过的其它书”。严格来说它带有基于物品(Item-based)协同过滤的味道,但实现上被简化成了SQL聚合加Java排序:先用当前用户在borrow_record里的借书列表圈定候选群体,再找出同样借过这些书的其他用户,统计这些用户还借了哪些当前用户没借过的书,按出现次数排序取TopN。
| 推荐策略 | 数据来源 | 优缺点 |
|---|---|---|
| 按分类随机 | book.category | 简单但无个性化 |
| 热度推荐 | borrow_record聚合 | 适合冷启动,跟随大众 |
| 相似用户协同过滤 | borrow_record关联查询 | 有个性化,但用户冷启动为空 |
| 时间加权协同过滤 | borrow_time参与计算 | 能跟随短期兴趣变化 |
4.2 第一步:取出用户借过的书
public List<Integer> getBorrowedBookIds(int userId) throws SQLException { String sql = "SELECT book_id FROM borrow_record WHERE user_id=? AND status=1"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, userId); try (ResultSet rs = ps.executeQuery()) { List<Integer> ids = new ArrayList<>(); while (rs.next()) ids.add(rs.getInt("book_id")); return ids; } } }注意status=1只统计已经归还的借阅,这样可以滤除当前手里还拿着没读完的书,让推荐结果更符合“历史偏好”。如果你希望推荐包含正在读的书,可以去掉这个条件,但容易出现“刚借的书马上推荐同主题”的重复感。
4.3 第二步:聚合相似用户与候选图书
拿到用户借过的bookId集合后,推荐数据层用in子句构造关联查询:
String sql = "SELECT br2.book_id, COUNT(*) AS cnt " + "FROM borrow_record br1 " + "JOIN borrow_record br2 ON br1.user_id = br2.user_id " + "WHERE br1.book_id IN (?,?,?) " + "AND br2.user_id != ? " + "AND br2.book_id NOT IN (?,?,?) " + "GROUP BY br2.book_id ORDER BY cnt DESC LIMIT 5";这个查询的意义分三层:br1先圈出“与当前用户借过相同书的那些借阅记录”,join br2拿到这些用户在其它时间借的书;用br2.user_id != userId排除自己,再用NOT IN排除自己已经借过的书;最后按出现次数cnt降序取前5本。这里参数个数是动态的,因此需要在Java里用StringBuilder叠加占位符,标准写法如下:
public List<Integer> recommendBySimilarUsers(int userId, int size) throws SQLException { List<Integer> borrowedIds = getBorrowedBookIds(userId); if (borrowedIds.isEmpty()) return Collections.emptyList(); String placeholders = String.join(",", Collections.nCopies(borrowedIds.size(), "?")); String sql = "SELECT br2.book_id, COUNT(*) AS cnt " + "FROM borrow_record br1 " + "JOIN borrow_record br2 ON br1.user_id = br2.user_id " + "WHERE br1.book_id IN (" + placeholders + ") " + "AND br2.user_id != ? " + "AND br2.book_id NOT IN (" + placeholders + ") " + "GROUP BY br2.book_id ORDER BY cnt DESC, br2.book_id LIMIT ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { int i = 1; for (Integer id : borrowedIds) ps.setInt(i++, id); ps.setInt(i++, userId); for (Integer id : borrowedIds) ps.setInt(i++, id); ps.setInt(i, size); ResultSet rs = ps.executeQuery(); List<Integer> result = new ArrayList<>(); while (rs.next()) result.add(rs.getInt("book_id")); return result; } }参数顺序与占位符顺序必须严格对应:先填第一个in子句,再填userId,再填第二个in子句,最后填limit。这个查询的执行计划在MySQL里会走borrow_record的联合索引(user_id, book_id),因此推荐查询不需要额外建临时表。
4.4 结果回填:保持推荐顺序
SQL按cnt排好序后,还需要把book_id回填成完整的图书信息。常见做法是批量查询:
public List<Book> fillBookDetail(List<Integer> bookIds) throws SQLException { String placeholders = String.join(",", Collections.nCopies(bookIds.size(), "?")); String sql = "SELECT book_id, book_name, author, category, publisher " + "FROM book WHERE book_id IN (" + placeholders + ")"; Map<Integer, Book> map = new LinkedHashMap<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < bookIds.size(); i++) { ps.setInt(i + 1, bookIds.get(i)); } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book b = new Book(); b.setBookId(rs.getInt("book_id")); b.setBookName(rs.getString("book_name")); b.setAuthor(rs.getString("author")); b.setCategory(rs.getString("category")); map.put(rs.getInt("book_id"), b); } } } return bookIds.stream().map(map::get).filter(Objects::nonNull).collect(Collectors.toList()); }ORDER BY cnt DESC只保证分组统计有序,IN子查询返回的行序并不能保证与bookIds一致;如果前端按位置渲染推荐列表,标题就可能对错书。这里先把查询结果放进LinkedHashMap,再按原始bookIds顺序取出,就是保证“统计排序”和“展示顺序”不脱节的关键。
5. 双驱动并存:MySQL到Oracle的迁移策略与排错
5.1 两个jar的加载机制
lib目录下mysql-connector-java-8.0.19.jar与ojdbc6-11.2.0.3.jar并存,不表示两个驱动都会生效。Java的DriverManager在Class.forName显式加载时就已经注册完成,后续getConnection会根据URL前缀自动选择驱动,jdbc:mysql://走MySQL驱动,jdbc:oracle:thin:@走Oracle驱动。为了让切换更直观,可以在DBUtil里加一个数据库类型开关:
private static final String DB_TYPE = "mysql"; // 切换成oracle时改为 oracle static { try { if ("oracle".equals(DB_TYPE)) { Class.forName("oracle.jdbc.driver.OracleDriver"); } else { Class.forName("com.mysql.cj.jdbc.Driver"); } } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } }MySQL驱动包含多个jar缓存到lib目录没有副作用,但要注意ojdbc6对应JDK 1.6及以上,如果你在JDK 11下运行,ojdbc6可能因模块系统限制报错,换成ojdbc8或ojdbc11更稳妥。
5.2 SQL方言差异
| 功能点 | MySQL | Oracle |
|---|---|---|
| 自增主键 | AUTO_INCREMENT | GENERATED BY DEFAULT AS IDENTITY |
| 分页 | LIMIT offset, size | OFFSET ? ROWS FETCH NEXT ? ROWS ONLY |
| 当前时间 | NOW() | SYSDATE |
| 字符串拼接 | CONCAT(a, b) | a || b |
| 日期减天数 | DATE_SUB(NOW(), INTERVAL 30 DAY) | SYSDATE - 30 |
| IN子句上限 | 不限 | 1000个表达式 |
这些差异在图书管理系统的三个核心SQL里都会遇到。图书查询的分页,MySQL写成LIMIT 0,10,Oracle 12c+要写成OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;日期比较,MySQL用DATE_SUB,Oracle用SYSDATE-30。我的做法是把这些差异抽到一个SQL常量类里,每个DAO方法根据DB_TYPE选择对应的SQL字符串,而不是在业务代码里做if判断。
5.3 连接与运行排错清单
| 报错现象 | 排查方向 |
|---|---|
| No suitable driver found | 驱动类名写错或lib下的jar未加入Build Path |
| UnsupportedClassVersionError | JDK版本低于驱动要求,8.x驱动需要JDK 1.8+ |
| Communications link failure | MySQL服务未启动或端口写成3307 |
| Access denied for user | 用户名密码错误或root只允许localhost |
| ORA-12154: TNS could not resolve | 连接串里的服务名写错 |
| ORA-01795: maximum number of expressions in a list is 1000 | IN列表超过1000项,需按500一组分片 |
如果推荐SQL里IN列表超过1000项,Oracle会直接拒绝执行。用户借阅历史特别长时,需要把borrowedIds分片,每500个ID查一次再合并结果。MySQL没有这个限制,所以同一套推荐逻辑在MySQL上正常、在Oracle上报错,这种情况优先怀疑这里。
6. 把推荐从“数量统计”升级为“时间加权”
如果按第四章的COUNT排序,对于借阅历史跨度特别长的用户,会出现把大二看过的C语言教材推荐给大三正在学Python的用户这种尴尬。一种落地做法是在聚合时引入时间衰减系数:最近30天内的借阅权重1.0,30~90天权重0.6,90天以上权重0.3,这样全部借阅历史都能参与计算,但旧行为的贡献逐渐减弱。
在MySQL 8.0中可以直接用窗口函数先截取用户最近5本已还图书作为兴趣锚点:
WITH user_recent AS ( SELECT user_id, book_id, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY borrow_time DESC) rn FROM borrow_record WHERE status = 1 ) SELECT book_id FROM user_recent WHERE rn <= 5;然后把窗口函数的结果作为推荐查询的输入,配合时间衰减计算最终得分:
SELECT br2.book_id, SUM(CASE WHEN br2.borrow_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1.0 WHEN br2.borrow_time >= DATE_SUB(NOW(), INTERVAL 90 DAY) THEN 0.6 ELSE 0.3 END) AS score FROM borrow_record br1 JOIN borrow_record br2 ON br1.user_id = br2.user_id WHERE br1.book_id IN (...) AND br2.user_id != ? AND br2.book_id NOT IN (...) GROUP BY br2.book_id ORDER BY score DESC LIMIT 5;这段SQL默认跑在MySQL 8.0上,换到Oracle时把DATE_SUB(NOW(), INTERVAL 30 DAY)换成SYSDATE - 30,把最后的LIMIT 5换成FETCH FIRST 5 ROWS ONLY即可。分页语法是两套数据库迁移时最明显的分水岭,建议在DAO层单独封装一个limitClause()方法,返回当前数据库对应的分页SQL尾巴,而不是散落在各个查询里。把上面这段加权SQL放进推荐DAO的recommendTimeWeighted()方法,替换掉第四章的COUNT版本,再对最近30天的数据做一次人工抽样比对,就能看到推荐清单明显偏向用户当前正在读的主题,而不是三年累积的旧偏好。
本文还有配套的精品资源,点击获取