news 2026/9/16 15:16:13

JDBC双驱动与协同过滤:图书管理系统多库兼容实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JDBC双驱动与协同过滤:图书管理系统多库兼容实战

简介:图书馆系统与图书推荐系统的完整实现代码与数据库脚本,面向需要完成同类课程设计或入门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_idINT主键,自增
book_nameVARCHAR(100)书名,用于检索
isbnVARCHAR(20)唯一约束,防重复录入
total_stockINT总入库量,不随借还变化
current_stockINT当前可借量,借减还加
categoryVARCHAR(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.forName8.x必须写com.mysql.cj.jdbc.Driver
连接URLserverTimezone不设置会报时区错误
查询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方言差异

功能点MySQLOracle
自增主键AUTO_INCREMENTGENERATED BY DEFAULT AS IDENTITY
分页LIMIT offset, sizeOFFSET ? 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
UnsupportedClassVersionErrorJDK版本低于驱动要求,8.x驱动需要JDK 1.8+
Communications link failureMySQL服务未启动或端口写成3307
Access denied for user用户名密码错误或root只允许localhost
ORA-12154: TNS could not resolve连接串里的服务名写错
ORA-01795: maximum number of expressions in a list is 1000IN列表超过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天的数据做一次人工抽样比对,就能看到推荐清单明显偏向用户当前正在读的主题,而不是三年累积的旧偏好。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 15:15:22

WorkBuddy Enterprise:企业级AI平台与Agent生态全解析

1. 为什么企业级AI平台需要Agent生态WorkBuddy Enterprise这个名字&#xff0c;拆开看就是三个关键词&#xff1a;WorkBuddy是产品线名称&#xff0c;Enterprise标明它的企业级定位&#xff0c;而真正撑起这套体系的是AI平台Agent生态的组合。今年做企业级AI应用的人应该都有同…

作者头像 李华
网站建设 2026/9/16 15:14:30

Android局域网文件互传:基于HTTP与UDP广播的实现方案

简介&#xff1a;这是一份面向安卓课程设计与局域网文件传输开发者的完整项目资料&#xff0c;内含设计报告文档与安卓工程源码&#xff0c;解决两台手机在同一个无线热点或路由器下、无需外网即可互传任意文件的问题。相比直连模式&#xff0c;用无线热点完成设备配对更简便。…

作者头像 李华
网站建设 2026/9/16 15:14:24

093、项目实战:WiFi模块ESP8266接口电路

093 项目实战:WiFi模块ESP8266接口电路 从一次诡异的“连不上网”说起 去年做一款智能插座,ESP8266模块焊了20片样板,18片正常工作,剩下两片死活连不上路由器。用串口助手看日志,AT指令返回正常,但一执行AT+CWJAP就卡死。折腾了两天,最后发现是模块的CH_PD(EN)引脚悬…

作者头像 李华
网站建设 2026/9/16 15:13:18

3D-DCT视频压缩原理与MATLAB实现

简介&#xff1a;本资源是一套面向本科及硕士阶段科研学习者的图像压缩重构实践方案&#xff0c;聚焦基于3D离散余弦变换&#xff08;3D-DCT&#xff09;的彩色图像快速压缩与重建技术&#xff0c;适用于图像处理、信号分析及多媒体编码等教学与仿真实验场景。压缩包共25个文件…

作者头像 李华
网站建设 2026/9/16 15:12:20

UE 的 \r\n 与 \n 互转教程,这次用 TaoToken 让 Codex 自动走通

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 15:11:04

MATLAB中DFT/FFT电力谐波分析:采样、频谱与功率计算

简介&#xff1a;这份rar压缩包围绕电力系统谐波与基波分析&#xff0c;提供基于MATLAB的DFT/FFT计算程序&#xff0c;适用于电气工程、电力电子方向学生或工程师完成谐波检测、有效值与相角计算。包内共6个文件&#xff0c;包含3个m脚本、2个mat数据文件和1个docx说明文档&…

作者头像 李华