简介:基于Java的校园二手商品交易系统设计源码,是一套面向Java学习者和校园开发者的完整项目实例,可用于掌握系统设计、模块拆分及JDBC数据库编程。资源共19个文件,以15个Java源文件为主,涵盖商品展示、发布、更新、删除与分页查询等核心功能,同时包含SQL脚本用于初始化数据库,properties文件配置连接信息,Idea项目配置文件便于直接导入开发环境,整体目录结构清晰,压缩包约60KB。系统在结构上划分为数据访问层、服务层和页面控制模块,JDBCUtils类封装了数据库操作细节,体现清晰的模块化思想,方便学习者逐步理解从数据层到业务层的调用关系,也提升了代码的可维护性。目前已有301人学习,借助该源码可快速建立对校园交易类系统开发流程的整体认识,也适合作为毕业设计或课程实践的参考。
1. 为什么我用原生 JDBC 讲这个校园二手交易项目
你可能会觉得奇怪:都 2025 年了,谁还拿 Swing + JDBC 写二手交易系统?可恰恰是这个没有 Spring Boot、没有 MyBatis 的项目,把 Java 基础里最容易忽略的几件事一次性碰全了:Connection谁开谁关、PreparedStatement为什么能防注入、LIMIT ?, ?分页参数怎么算、多个页面如何共用同一个InfoService。对一个刚学完集合和 IO、还没接触过 Web 框架的开发者来说,这套源码的价值不在“界面好看”,而在于能跟着 15 个 Java 文件把一条完整的 CRUD 链路走通。
项目本身是校园二手商品交易系统,核心操作就四件事:发布信息、浏览列表、修改内容、删除条目。没有支付、没有权限、没有消息推送,业务边界恰好收敛到一个 Java 初学者能完全消化的范围。适合三类人看:一是正在学 Java 基础课程、需要把语法落地的学生;二是准备 Java 面试、想拿 JDBC 细节讲出真实项目的候选人;三是接手过老系统、想快速复盘 Swing 工程结构的开发。如果你期待微服务和高并发,这个项目不适合你;如果你想用一个晚上把“DAO 模式 + JDBC + 分页 + 时间组件”全部串起来,它比大部分演示代码都完整。
2. 从文件清单入手,把 15 个 Java 文件拆成职责地图
2.1 文件到模块的对应关系
拿到upload.zip之后先别急着运行,第一步是把文件分类。这套项目的文件名本身就可读性很强,基本能看到“页面类、工具类、数据访问类、服务类”四个维度。我从解压后的结构里整理出一张映射表,建议直接对照着打开文件:
| 文件 | 所属层 | 职责 |
|---|---|---|
Main.java | 入口 | 启动程序,通常负责初始化主窗口 |
HomePage.java | 视图 | 主页展示,承载商品信息列表 |
QueryAllPage.java | 视图/控制 | 分页查询结果展示页 |
AddInfoPage.java | 视图 | 商品添加表单页 |
UpdateInfoPage.java | 视图 | 商品信息修改页 |
DeleteInfoPage.java | 视图 | 删除确认与操作页 |
SetTimePage.java | 视图 | 时间设置页面,常与时间选择器配合 |
InfoDao.java | 接口 | 定义商品信息的数据访问接口 |
InfoDaoImpl.java | 数据访问层 | 接口实现,写具体 SQL 和 JDBC 操作 |
InfoService.java | 服务层 | 业务逻辑封装,组合 DAO 调用 |
JDBCUtils.java | 工具类 | 管理数据库连接与资源释放 |
Info.java | 实体类 | 对应商品信息表结构的 Java Bean |
Time.java | 实体/工具 | 与时间字段相关的时间值对象 |
JTimeChooser.java | 组件 | Swing 时间选择控件 |
StringUtils.java | 工具类 | 空值判断、字符串规范化处理 |
db.properties | 配置 | 数据库驱动、连接地址、账号密码 |
sql.sql | 脚本 | 建库、建表、初始数据 |
readme.txt | 文档 | 运行环境和启动说明 |
trade.iml | 工程配置 | IntelliJ IDEA 模块描述文件 |
从这张表能看出来,项目没有硬拆 Controller,而是把页面类直接当作入口调用InfoService。这样的分层在 Swing 项目里很常见:视图类自己监听按钮事件,事件里调用 Service,Service 再通过接口反转去调InfoDaoImpl。好处是数据访问逻辑被隔离在 DAO 层,换数据库或者改连接池时不需要碰页面。
2.2 我建议的阅读顺序:先数据、再 DAO、最后页面
拿到源码后不建议按文件名排序一个个看,那样很容易陷进JTimeChooser的绘制细节里出不来。我一般先看sql.sql,弄清楚商品信息表到底有哪些字段、主键是什么、哪些字段允许为空。然后看db.properties,确认数据库连接用的是 MySQL 还是别的数据库,账号密码默认是什么。接着进入JDBCUtils.java,搞清楚连接是怎么拿、怎么关的。再顺着接口InfoDao.java看实现类InfoDaoImpl.java,最后回到页面类看按钮监听。
这套顺序本质上是“自底向上”:先懂数据长什么样,再看数据怎么落库,最后才看页面上怎么用。很多新手把顺序反过来,从HomePage.java开始读,结果被 Swing 的布局代码分散注意力,连InfoService在中间做了什么都没看出来。下面这个命令可以把源码里的 Java 文件单独列出来,方便按自己的节奏统计进度:
unzip -o upload.zip -d trade-system cd trade-system find . -name "*.java" | sort解压后用find列出 15 个 Java 文件的完整路径,配合wc -l能顺便统计每个文件的代码行数,快速判断哪个文件是核心、哪个是辅助。对于这种课程设计类项目,通常InfoDaoImpl.java和HomePage.java的行数会明显多于其他文件,这两个就是阅读重点。
2.3 trade.iml 不参与业务,但决定你能否直接启动
trade.iml是 IntelliJ IDEA 的模块文件,它不包含业务代码,却决定了项目导入后能不能直接用。里面主要记录模块名、源码目录、JDK 级别和依赖库的引用。你如果用 IDEA 打开项目,它会根据.iml自动识别源码入口;如果直接用命令行javac编译,这个文件就没有任何作用。
一个值得注意的点是:.iml里记录的依赖如果是相对路径,换机器后容易失效。课堂上拷贝过来的源码,经常因为本地lib目录位置不一样,导致JDBCUtils编译时报找不到驱动类。遇到这种情况不要改代码,先看readme.txt里写的运行要求,再检查 classpath 里是否包含了 MySQL 驱动 JAR。这类问题在课程设计中占排错场景的一半以上,养成先看配置文件再怀疑代码的习惯,能省下大量时间。
3. 深入 InfoDaoImpl:连接管理、SQL 注入与分页查询
3.1 JDBCUtils 到底该封装什么
JDBCUtils.java是整个项目的数据库基础设施。它不是一个业务类,却决定了所有 DAO 方法的可靠程度。合格的工具类至少应该封装三件事:加载驱动、获取连接、释放资源。我看到很多学生项目把DriverManager.getConnection()直接写在每个 DAO 方法里,导致配置散落各处,换一次数据库要改七八个文件。
常见做法是:用静态代码块加载驱动,从db.properties读取连接参数,对外暴露getConnection()和close()两个静态方法。下面是一个符合该项目风格的封装骨架:
public class JDBCUtils { private static String url; private static String user; private static String password; static { try { Properties props = new Properties(); props.load(JDBCUtils.class.getClassLoader() .getResourceAsStream("db.properties")); Class.forName(props.getProperty("driver")); url = props.getProperty("url"); user = props.getProperty("username"); password = props.getProperty("password"); } catch (Exception e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }这段代码把Class.forName、参数加载和资源关闭集中到一处,DAO 里只需要调用JDBCUtils.getConnection()再调close()即可。close()接收三个参数是刻意的:ResultSet、Statement、Connection关闭顺序不能反,否则小内存环境下频繁查询会出现游标未释放的警告。这个 API 设计的另一个好处是让调用方一眼看到“谁打开了资源谁负责关闭”,避免连接泄漏。
3.2 PreparedStatement 骨架与常见错误
InfoDaoImpl.java的核心方法是增删改查,其中最容易被问细节的是查询方法。查询分为两类:一类是根据 id 查单条记录,用于编辑回显;另一类是分页查列表,用于主页展示。单条查询的关键在于 id 参数必须通过占位符传入,不能拼字符串。
public Info findById(int id) { String sql = "SELECT id, title, price, description, publish_time FROM info WHERE id = ?"; try (Connection conn = JDBCUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { Info info = new Info(); info.setId(rs.getInt("id")); info.setTitle(rs.getString("title")); info.setPrice(rs.getBigDecimal("price")); info.setDescription(rs.getString("description")); info.setPublishTime(rs.getTimestamp("publish_time")); return info; } } } catch (SQLException e) { e.printStackTrace(); } return null; }代码里的 try-with-resources 是 Java 7 之后的推荐写法,Connection、PreparedStatement、ResultSet全部自动关闭,相当于把JDBCUtils.close()的调用内置化。ps.setInt(1, id)的意思是把第一个占位符替换为 id 变量的值,参数下标从 1 开始而不是从 0 开始,这是新手最容易错的地方。setBigDecimal对应数据库中的DECIMAL类型,比用setString转parseDouble更安全,能避免浮点误差。
如果项目里到处都是字符串拼接的Statement,看到where id = " + id这种写法要立刻意识到两点:一是性能上每次拼接都会让数据库重新解析 SQL;二是安全上有注入风险,用户输入1 or 1=1会让 where 条件永远成立。PreparedStatement 把 SQL 结构和参数分离,数据库先编译后传参,注入字符不再具备改变语句结构的能力。
3.3 LIMIT 分页的参数计算与 count 查询
QueryAllPage.java负责展示分页结果,这也是整个项目里最值得讲的查询场景。MySQL 分页的标准写法是LIMIT offset, pageSize,其中 offset 表示跳过多少条记录,计算方式是(currentPage - 1) * pageSize。比如第一页每页 10 条,offset 就是 0;第二页就是 10,第三页就是 20。
public List<Info> findByPage(int currentPage, int pageSize) { String sql = "SELECT id, title, price, publish_time FROM info ORDER BY publish_time DESC LIMIT ?, ?"; List<Info> list = new ArrayList<>(); try (Connection conn = JDBCUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { int offset = (currentPage - 1) * pageSize; ps.setInt(1, offset); ps.setInt(2, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Info info = new Info(); info.setId(rs.getInt("id")); info.setTitle(rs.getString("title")); info.setPrice(rs.getBigDecimal("price")); list.add(info); } } } catch (SQLException e) { e.printStackTrace(); } return list; }这段代码里LIMIT ?, ?的两个参数分别对应 offset 和 pageSize。第一个参数由页码计算而来,第二个参数是固定的每页条数。这里有一个容易被忽略的坑:如果页面上点击“下一页”时页码是从 0 开始记的,那 offset 公式要相应调整为currentPage * pageSize,否则第二页会重复显示第一页的数据。项目里一般会在HomePage.java或QueryAllPage.java中维护一个currentPage变量,翻页时先递增再调用本方法。
配套还需要一个总记录数查询,通常写成SELECT COUNT(*) FROM info,用于计算总页数。总页数公式是(totalCount + pageSize - 1) / pageSize,这种写法能避免整除取不到小数导致最后一页丢失的问题。如果你的课程需要展示“共 X 条 / 共 X 页”这类信息,就靠这两个查询配合完成。
3.4 db.properties 与 sql.sql 的联动
db.properties是 JDBC 参数的唯一配置源,它的内容决定了JDBCUtils能不能连上数据库。常见配置如下:
driver=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/trade_db?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai username=root password=123456driver是 MySQL 8.x 的驱动类名,老项目里如果写的是com.mysql.jdbc.Driver,在 MySQL 8 驱动下会有过时警告但不影响运行。url里的characterEncoding=utf8控制中文写入是否乱码,serverTimezone指定服务器时区,不配的话高版本 MySQL 会报时间字段转换异常。username和password要和你本地 MySQL 一致,这是新手跑不通项目的头号原因。
sql.sql则是数据库结构的蓝本。执行它时需要先确认默认数据库名是否与db.properties一致,比如配置文件里写trade_db,脚本里也要 CREATE DATABASE 同名库。常见字段设计基本围绕商品信息展开:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT AUTO_INCREMENT | 主键,自增长 |
| title | VARCHAR(100) | 商品标题 |
| price | DECIMAL(10,2) | 价格,保留两位小数 |
| description | TEXT | 商品描述 |
| publish_time | DATETIME | 发布时间 |
| contact_way | VARCHAR(50) | 联系方式,可空 |
字段命名和 Java 实体类Info.java的属性名保持驼峰映射对应关系。JDBC 查询返回的列名不区分大小写,但rs.getInt("id")的字符串必须和 SQL 里查出的列名完全一致,大小写在这里通常不敏感,下划线和驼峰映射关系是需要注意的。如果 SQL 里用了select publish_time,而实体字段叫publishTime,代码里就必须rs.getTimestamp("publish_time"),不能写成publishTime。
4. 业务页面如何调用 Service:AddInfoPage、UpdateInfoPage、DeleteInfoPage
4.1 HomePage 的跳转逻辑
整个项目的入口是Main.java,它负责创建HomePage并让它可见。HomePage.java既充当主页又承担路由职责:页面上通常有“新增”“修改”“删除”“刷新”几个按钮,每个按钮的监听器里创建一个新的 JFrame 或 Dialog。这种写法在 Swing 项目里最常见,因为 JFrame 不能像 Web 框架那样直接 forward,只能手动 new 然后 dispose。
addButton.addActionListener(e -> { new AddInfoPage().setVisible(true); // 可以在这里传入当前用户id,用于记录发布者 }); queryAllButton.addActionListener(e -> { new QueryAllPage().setVisible(true); });按钮监听里直接创建页面对象是标准做法,但要注意不要在这些事件里写数据库操作。界面线程执行 JDBC 查询会导致 Click 之后窗口卡住,表现是按钮按下去界面白屏。正确做法是页面创建时先拿InfoService把初始数据查出来,渲染完成后再响应用户操作。如果你的HomePage在打开时比较慢,优先检查它是不是在构造函数里同步查了全表数据。
4.2 AddInfoPage 与 JTimeChooser 的时间处理
新增页面的流程是:用户填写标题、价格、描述,选择发布时间,点保存按钮。这里有两个实现细节很关键:价格字段的校验和时间字段的组装。
AddInfoPage的保存按钮监听器里通常会这样处理:
String title = titleField.getText().trim(); String priceText = priceField.getText().trim(); if (StringUtils.isEmpty(title) || StringUtils.isEmpty(priceText)) { JOptionPane.showMessageDialog(this, "标题和价格不能为空"); return; } Info info = new Info(); info.setTitle(title); info.setPrice(new BigDecimal(priceText)); info.setPublishTime(timeChooser.getSelectedTime()); infoService.add(info);StringUtils.isEmpty做非空判断,避免了直接调用trim()后忘记判空的问题。价格用BigDecimal包装是为了精确表示金额,如果用Double转换,0.1 加 0.2 会得到一串浮点误差。timeChooser.getSelectedTime()返回的是java.util.Date,但数据库字段是DATETIME,JDBC 驱动能自动完成转换,不需要手动格式化。
JTimeChooser.java是一个自定义的 Swing 时间选择组件。它的存在说明项目里有多处需要设定时间:发布商品时要选发布时间,修改商品时可以改上架时间。SetTimePage.java则可以理解为独立的时间设置对话框,它内部实例化JTimeChooser并返回用户选择的时间值。这两者配合使用时,建议把返回格式统一成java.sql.Timestamp,避免页面层再去解析字符串产生 locale 差异。
4.3 UpdateInfoPage 的回显与条件更新
修改页面是这类系统里最容易出 bug 的地方。常见错误是点击某条记录进入修改页后,所有字段都是空的,因为页面构造函数没有把当前条目的 id 传进去。正确的流程是:主页表格选中某一行,拿到该行的 id,传给UpdateInfoPage的构造函数,构造函数里调用InfoService.findById(id)把数据回显到文本框。
public UpdateInfoPage(int id) { this.id = id; initUI(); Info info = infoService.findById(id); if (info != null) { titleField.setText(info.getTitle()); priceField.setText(info.getPrice().toString()); timeChooser.setSelectedTime(info.getPublishTime()); } }回显逻辑里有三个细节值得注意。第一,infoService.findById(id)返回 null 的情况要处理,否则页面会直接抛空指针。第二,价格字段用toString()回显时,BigDecimal会保留小数位数,和用户输入的格式可能不一致,最好先stripTrailingZeros().toPlainString()处理。第三,保存时的 update SQL 必须限定主键条件:
UPDATE info SET title = ?, price = ?, description = ?, publish_time = ? WHERE id = ?如果没有最后的WHERE id = ?,执行一次 update 会把整张表所有记录都改成相同内容,这是课程设计里最致命的数据事故之一。代码里一定要确认PreparedStatement的占位符顺序与 SQL 列顺序一一对应,尤其在列数量多的时候,建议把一个 update 语句的列控制在 5 个以内,既方便排错也方便复用。
4.4 DeleteInfoPage 与 StringUtils 的非空校验
删除页相对简单,但还是有实现上的陷阱。最稳妥的做法是:先按 id 查询出要删除的记录,把标题展示给用户确认,再执行删除。很多项目省掉查询这一步,直接弹窗“确认删除”,用户根本不知道删的是哪条,误删之后数据不可恢复。
删除的 DAO 方法建议写成:
public int deleteById(int id) { String sql = "DELETE FROM info WHERE id = ?"; try (Connection conn = JDBCUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } }executeUpdate()返回值表示影响的行数。如果返回 0,说明 id 对应的记录不存在,页面上应该提示“该商品已被删除,请刷新列表”。如果返回大于 1,那就要怀疑是否真的只传了一个 id,因为 DELETE 不带 where 条件时会对全表生效。另外一个容易忽略的点是:删完数据后,后续新增记录的 id 不会回退,这是自增主键的固有行为,不属于 bug。
StringUtils.java在这个流程里承担的是入参校验职责。除了判空,通常还会封装一个isNumeric方法,防止价格输入框里出现字母。这里我建议项目里显式过滤掉 HTML 标签字符,比如把<和>替换成全角符号,不然后续展示商品描述时可能出现样式被注入的问题。虽然 Swing 不解析 HTML,但数据将来如果迁移到 Web 端,这些脏数据是有潜在风险的。
4.5 事务边界放在 Service 层的原因
InfoService.java是整个业务逻辑的中转站。它可以把多个 DAO 操作组合成一个完整流程,比如“发布商品”不仅要往info表插入数据,还可能要往日志表里插入一条操作记录。这两个操作要么都成功,要么都失败,中间任何一步出错都应该回滚。
public boolean add(Info info) { Connection conn = null; try { conn = JDBCUtils.getConnection(); conn.setAutoCommit(false); infoDao.insert(info); logDao.insertLog("add_info", info.getId()); conn.commit(); return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } e.printStackTrace(); return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) {} } } }setAutoCommit(false)的含义是关闭每一次 SQL 执行后的自动提交,让两条 insert 在同一个事务里,只有commit()后才会真正写入数据库。这里最关键的约束是:这个Connection必须由 Service 创建并传递给 DAO 使用,不能是每个 DAO 方法自己再去JDBCUtils.getConnection(),否则两个连接各管各的事务,rollback()只能回滚第二个连接上未提交的操作。理解了这条边界,你在面试里解释 Spring@Transactional的传播行为时就有了画面感。
5. 本地跑通与面试复用:从分页封装到连接池替换
5.1 从零启动这套系统的完整命令
先确保本机装了 JDK 8 以上版本和 MySQL 5.7/8.x。打开终端依次执行下面几步:
# 1. 建库导表 mysql -uroot -p < sql.sql # 2. 确认 db.properties 里的账号密码和库名 cat db.properties # 3. 编译所有 Java 源码 javac -encoding UTF-8 -cp ".:lib/mysql-connector-java-8.0.33.jar" *.java # 4. 运行入口类 java -cp ".:lib/mysql-connector-java-8.0.33.jar" Main如果本地没有 MySQL 驱动 JAR,去 Maven 中央仓库下载mysql-connector-java,放到lib目录下。运行后能弹出主窗口,说明环境没问题。如果报ClassNotFoundException,先检查 jar 包路径是否对;如果报中文乱码,把-Dfile.encoding=UTF-8加到 java 命令里。验证数据写入是否成功,可以在另一个终端执行:
SELECT id, title, price, publish_time FROM info ORDER BY publish_time DESC LIMIT 5;看到刚添加的商品出现在结果里,说明 DAO 层的 insert 和查询链路都是通的。如果插入了中文但查询显示乱码,优先检查db.properties里的characterEncoding=utf8是否拼写正确。
5.2 手写一个分页结果封装,面试直接讲故事
课程设计里的分页通常是裸返回List<Info>,页面上再单独查一次 count。这在实际项目中不够用,面试官也容易追问“总页数在哪里算的”。我建议把这套系统的分页改造成标准的PageResult<T>封装,既能锻炼泛型,也能在面试展示代码设计能力:
public class PageResult<T> { private List<T> list; private int totalCount; private int pageSize; private int currentPage; public int getTotalPages() { return (totalCount + pageSize - 1) / pageSize; } public boolean hasNext() { return currentPage < getTotalPages(); } }PageResult<T>用泛型参数 T 接收任意实体类型,Info、User、Order都能复用。totalPages的算法在 5.2 节中已经提到,这里强调它的边界条件:当totalCount为 0 时,getTotalPages()返回 0,页面上就不能显示“下一页”按钮。hasNext()方法用来控制翻页按钮的可用状态,比前端拿“当前页是否小于总页数”更符合面向对象封装的习惯。
在 InfoService 里加一个findPage(int currentPage, int pageSize)方法,先查 count 再查列表,最后组装成PageResult<Info>。面试时你就可以说:我没有把分页逻辑散落在 Swing 页面里,而是用泛型封装成公共的 PageResult,之后换到 Web 层也能直接复用。这句话比“我实现了分页功能”有说服力得多。
5.3 把 StringUtils 和 Connection 变成加分项
这个项目里最容易继续延伸的还有两处。第一是连接池替换:把JDBCUtils.getConnection()里的DriverManager.getConnection(url, user, password)换成 HikariCP 的连接获取方式。HikariCP 只需要三行配置就能替换:
HikariConfig config = new HikariConfig(); config.setJdbcUrl(url); config.setUsername(user); config.setPassword(password); DataSource dataSource = new HikariDataSource(config);DataSource是 JDBC 规范里的标准接口,替换后所有 DAO 方法都不需要改动,因为getConnection()方法内部的实现被隐藏了。这一处改造可以向面试官证明:你理解连接池的原理,并且知道 DAO 层应该依赖DataSource抽象而不是具体实现。
第二是StringUtils的扩展方向。当前项目里它只做判空,你可以往里补一个sanitizeInput(String input)方法,把传入的字符串中可能的控制字符和 HTML 标签过滤掉,再配合PreparedStatement做参数绑定,形成双保险。这样AddInfoPage里就多了一个真实的业务校验动作,也让整个项目的健壮性提升一截。等本地跑通这套流程之后,你会发现自己对 JDBC 的“连接怎么来、SQL 怎么执行、资源怎么释放”这三件事有了回答得出来的细节支撑。
本文还有配套的精品资源,点击获取