☰
Java采购管理系统实战:从数据库设计到事务一致性
2026/9/28 23:59:19 网站建设 项目流程

简介:这是一套面向Java Web初学者与课程设计者的采购管理系统完整源码,采用JSP技术搭建,配合MySQL数据库,用于解决企业采购信息的管理问题,适合作为毕业设计、课程大作业或进销存类项目的参考模板。系统实现了用户登录、供应商管理、材料管理三大核心模块,登录环节对用户名与密码进行校验,并区分普通用户与超级管理员的操作页面,供应商模块支持灵活添加与修改供货商信息,材料模块则负责材料种类与库存的维护。压缩包共248个文件,约22.71MB,包含32个java源文件、30个jsp页面、35个class编译文件、63个jar依赖包,以及xml配置、gif与png图片、css样式、properties配置等资源,结构完整,便于直接导入运行与二次开发。目前已有2659人学习下载,读者可从中获取分层清晰的Action、Service与实体类设计思路,理解JSP与MySQL的整合方式,并参考页面跳转与权限控制的实现细节,快速完成自己的采购管理项目。

1. Java 采购管理系统:从课程设计到能跑起来的第一版

很多 Java 课程设计案例源码下载下来第一件事就是跑不起来,数据库连不上、依赖缺一半、SQL 脚本对不上实体类字段。采购管理系统这个题目在高校课程设计和初级面试里出现频率极高,原因很直接:它同时覆盖了面向对象编程 Java 的核心语法、数据库增删改查的完整链路、以及一个真实业务场景下的事务处理需求。你拿到一个「Java实现采购管理系统(含数据库).rar」,里面大概率是 Eclipse 或 IDEA 工程加一份 SQL 文件,能不能直接用于答辩或二次开发,取决于你有没有把数据层和业务层拆清楚。

这篇文章面向三类人:正在做数据库课程设计需要交作品的学生、想拿采购系统当 Spring Boot 练手项目的初级开发者、以及面试前需要梳理 Java 怎么保证数据一致性这类八股文的求职者。我会按「先跑通最小闭环,再补业务逻辑,最后处理并发和事务」的顺序,把建库建表、JDBC 连接、采购单增删改查、库存联动、事务控制这几个环节拆开讲。你不需要有现成源码,跟着步骤能从零搭出一个可演示的版本。

2. 数据库设计与建表:采购单、供应商、库存三张核心表怎么定

2.1 先想清楚采购流程里有哪些实体

采购管理系统的业务链条不复杂:采购员选供应商、下采购单、采购单包含多个商品明细、商品入库后库存增加。对应到数据库设计,至少需要五张表:供应商表 supplier、商品表 product、采购单主表 purchase_order、采购单明细表 purchase_order_item、库存表 inventory。很多课程设计案例源码里把明细直接塞进主表用逗号分隔,这种设计在查询和统计时非常痛苦,不建议模仿。

主表和明细表拆开的好处是:一张采购单可以包含任意数量的商品,每个商品有独立的单价和数量,总金额通过聚合计算得出而不是存冗余字段。下面是我一般会用的建表 SQL,字段类型按 MySQL 8.0 写,其他数据库改一下自增语法即可。

-- 供应商表 CREATE TABLE supplier ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '供应商名称', contact VARCHAR(50) COMMENT '联系人', phone VARCHAR(20) COMMENT '联系电话', status TINYINT DEFAULT 1 COMMENT '1启用 0停用', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 商品表 CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category VARCHAR(50), unit VARCHAR(10) COMMENT '计量单位', price DECIMAL(12,2) NOT NULL DEFAULT 0.00, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 采购单主表 CREATE TABLE purchase_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '采购单号', supplier_id BIGINT NOT NULL, total_amount DECIMAL(14,2) DEFAULT 0.00, status TINYINT DEFAULT 0 COMMENT '0待审核 1已审核 2已入库 3已取消', created_by VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_supplier (supplier_id), INDEX idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 采购单明细表 CREATE TABLE purchase_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(12,2) NOT NULL, subtotal DECIMAL(14,2) GENERATED ALWAYS AS (quantity * unit_price) STORED, INDEX idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存表 CREATE TABLE inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL UNIQUE, quantity INT NOT NULL DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段 SQL 里几个参数值得说明。DECIMAL(12,2)用于金额,绝不能用 FLOAT 或 DOUBLE,否则累加会出现精度丢失,这是血泪经验。subtotal用了生成列,MySQL 5.7 以上支持,好处是插入明细时不用手动算小计,查询时直接读。order_no加了唯一索引,防止重复提交产生相同单号。inventory表的product_id唯一约束保证一个商品只有一条库存记录,更新时用INSERT ... ON DUPLICATE KEY UPDATE就能实现 upsert。

2.2 索引和状态字段的设计取舍

采购单的status字段用 TINYINT 而不是 ENUM,原因是 ENUM 在后续加状态时需要 ALTER TABLE,而 TINYINT 配合代码里的常量类更灵活。索引方面,supplier_id和status各建一个单列索引,如果查询条件经常是「某供应商的待审核单」,可以考虑建联合索引idx_supplier_status (supplier_id, status)。但不要一上来就堆索引,采购系统数据量通常不大,索引过多反而拖慢插入。

明细表的order_id索引是必须的,因为查采购单详情时一定会WHERE order_id = ?。外键约束我一般不加,原因是课程设计环境里经常需要手动删数据调试,外键会让删除顺序变得很麻烦。如果你要做严谨的数据库课程设计,可以加上FOREIGN KEY ... ON DELETE CASCADE,但要在文档里说明。

注意:建表时统一用 utf8mb4 字符集,避免商品名称里有生僻字或 emoji 时插入失败。这个坑在微信数据库解密相关的讨论里也常被提到,本质都是字符集问题。

3. JDBC 连接与 DAO 层:不用框架怎么把增删改查写干净

3.1 数据库连接池选型与配置

课程设计里最常见的翻车场景是每个方法都DriverManager.getConnection(),跑几十次查询就报连接超时。正确做法是用连接池。Java 生态里最轻量的选择是 HikariCP,一个 jar 包搞定,配置也简单。如果你用的是 Maven,加依赖:

<dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.1.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>

连接池的初始化代码我一般写成一个工具类,全局只初始化一次:

import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; public class DBUtil { private static final HikariDataSource DATA_SOURCE; static { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/purchase_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4"); config.setUsername("root"); config.setPassword("your_password"); config.setMaximumPoolSize(10); // 最大连接数 config.setMinimumIdle(2); // 最小空闲连接 config.setConnectionTimeout(30000); // 获取连接超时 30s config.setIdleTimeout(600000); // 空闲回收 10min config.setMaxLifetime(1800000); // 连接最大存活 30min DATA_SOURCE = new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return DATA_SOURCE.getConnection(); } public static DataSource getDataSource() { return DATA_SOURCE; } }

参数说明:maximumPoolSize设 10 对课程设计足够,生产环境按CPU核数 * 2 + 磁盘数估算。serverTimezone必须设,否则 MySQL 8 驱动会报时区错误。characterEncoding=utf8mb4和建表字符集对应。maxLifetime要比数据库的wait_timeout小,否则连接被服务端断开后池里还留着死连接。

3.2 采购单新增:主表加明细的事务写法

采购单新增是整个系统里最需要保证数据一致性的操作。主表插一条、明细表插 N 条,任何一步失败都必须回滚。下面是一个不依赖 Spring 的纯 JDBC 实现:

public class PurchaseOrderDao { public long createOrder(PurchaseOrder order, List<PurchaseOrderItem> items) throws SQLException { String orderSql = "INSERT INTO purchase_order (order_no, supplier_id, total_amount, status, created_by) VALUES (?, ?, ?, ?, ?)"; String itemSql = "INSERT INTO purchase_order_item (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)"; Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 插入主表 long orderId; try (PreparedStatement ps = conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, order.getOrderNo()); ps.setLong(2, order.getSupplierId()); ps.setBigDecimal(3, order.getTotalAmount()); ps.setInt(4, order.getStatus()); ps.setString(5, order.getCreatedBy()); ps.executeUpdate(); try (ResultSet rs = ps.getGeneratedKeys()) { rs.next(); orderId = rs.getLong(1); } } // 2. 批量插入明细 try (PreparedStatement ps = conn.prepareStatement(itemSql)) { for (PurchaseOrderItem item : items) { ps.setLong(1, orderId); ps.setLong(2, item.getProductId()); ps.setInt(3, item.getQuantity()); ps.setBigDecimal(4, item.getUnitPrice()); ps.addBatch(); } ps.executeBatch(); } conn.commit(); return orderId; } catch (SQLException e) { if (conn != null) { conn.rollback(); // 任何异常都回滚 } throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); // 归还连接到池 } } } }

逻辑说明:setAutoCommit(false)关闭自动提交后,主表和明细表的插入在同一个事务里。RETURN_GENERATED_KEYS拿到自增主键,作为明细表的order_id。addBatch()加executeBatch()比循环单条插入快一个数量级,明细多的时候尤其明显。catch 块里 rollback 是后悔药,没有它一旦明细插入失败,主表会留下一条脏数据。finally 里恢复 autoCommit 再 close,是因为连接池归还的连接必须回到初始状态。

参数方面,total_amount建议在 Java 层算好再传,不要依赖数据库生成列跨表计算。status初始值设 0 表示待审核,后续审核和入库分别改成 1 和 2。

3.3 采购单查询:分页和条件拼接

查询列表页需要支持按供应商、状态、时间范围过滤,还要分页。拼接 SQL 时用 StringBuilder 加参数列表,不要用字符串拼接值,否则有 SQL 注入风险:

public List<PurchaseOrder> queryOrders(Long supplierId, Integer status, int page, int size) throws SQLException { StringBuilder sql = new StringBuilder("SELECT * FROM purchase_order WHERE 1=1"); List<Object> params = new ArrayList<>(); if (supplierId != null) { sql.append(" AND supplier_id = ?"); params.add(supplierId); } if (status != null) { sql.append(" AND status = ?"); params.add(status); } sql.append(" ORDER BY created_at DESC LIMIT ? OFFSET ?"); params.add(size); params.add((page - 1) * size); List<PurchaseOrder> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); } } } return list; }

WHERE 1=1是个惯用技巧,让后续每个条件都能以AND开头,不用判断是不是第一个条件。LIMIT ? OFFSET ?是 MySQL 的分页写法,Oracle 要用ROWNUM,达梦数据库兼容 MySQL 语法但分页建议用LIMIT。setObject能自动处理 Long 和 Integer 的装箱类型,比逐个判断类型再 setLong/setInt 简洁。

4. 库存联动与事务一致性:入库时怎么保证不超卖不丢单

4.1 入库操作的原子性设计

采购单审核通过后要入库,入库动作包含两步:更新采购单状态为「已入库」、增加对应商品的库存。这两步必须在同一个事务里,否则会出现单子状态改了但库存没加的丢单情况。库存更新用 upsert 语句:

INSERT INTO inventory (product_id, quantity) VALUES (?, ?) ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity);

这条语句在 MySQL 里是原子的,不需要先 SELECT 再 UPDATE。VALUES(quantity)引用的是 INSERT 部分传入的值。如果你的数据库不支持这种语法,比如某些版本的 SQLite,就要用INSERT OR REPLACE或者先查后改加行锁。

入库的 Java 方法结构:

public void stockIn(long orderId) throws SQLException { String updateOrder = "UPDATE purchase_order SET status = 2 WHERE id = ? AND status = 1"; String updateStock = "INSERT INTO inventory (product_id, quantity) VALUES (?, ?) " + "ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity)"; String queryItems = "SELECT product_id, quantity FROM purchase_order_item WHERE order_id = ?"; Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 更新单状态,带 status=1 条件防止重复入库 try (PreparedStatement ps = conn.prepareStatement(updateOrder)) { ps.setLong(1, orderId); int affected = ps.executeUpdate(); if (affected == 0) { throw new SQLException("订单状态不允许入库或已入库"); } } // 2. 查明细 List<long[]> items = new ArrayList<>(); try (PreparedStatement ps = conn.prepareStatement(queryItems)) { ps.setLong(1, orderId); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { items.add(new long[]{rs.getLong("product_id"), rs.getLong("quantity")}); } } } // 3. 逐条更新库存 try (PreparedStatement ps = conn.prepareStatement(updateStock)) { for (long[] item : items) { ps.setLong(1, item[0]); ps.setLong(2, item[1]); ps.addBatch(); } ps.executeBatch(); } conn.commit(); } catch (SQLException e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } }

关键点在UPDATE purchase_order SET status = 2 WHERE id = ? AND status = 1。加上status = 1条件后,如果订单已经是状态 2,executeUpdate返回 0,直接抛异常回滚。这比先查状态再更新更可靠,因为查和更新之间可能有并发。这就是 Java 怎么保证数据一致性的一个具体落地:用数据库的条件更新做乐观锁,而不是在应用层加 synchronized。

4.2 并发场景下的库存扣减与超卖防范

采购系统一般不会像秒杀那样高并发,但多个采购员同时入库同一商品时,库存更新仍可能出问题。ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity)本身是行级锁,不会丢更新。但如果你用的是「先 SELECT 当前库存,Java 里加完再 UPDATE」的写法,两个线程同时读到 10,各加 5,最终库存可能是 15 而不是 20。

防范方法有三种。第一种是用上面的原子 upsert,最简单。第二种是在 SELECT 时加FOR UPDATE行锁,但会降低并发。第三种是用版本号字段做乐观锁,适合更新频率低但冲突检测要求高的场景。课程设计里第一种足够,面试时三种都要能说清楚。

提示:如果你的数据库是 Oracle 或达梦,upsert 语法不同。Oracle 用MERGE INTO,达梦兼容 Oracle 模式时也用 MERGE。写 DAO 层时可以把这段 SQL 抽成配置,换数据库时只改 SQL 不改 Java 代码。

5. 避坑与排查:采购系统开发中最容易翻车的五个地方

5.1 中文乱码从建库到 JDBC 要查四层

现象:商品名称插入后显示问号,或者查询条件带中文查不到数据。原因通常出在四层中的某一层:数据库字符集、表字符集、JDBC URL 的 characterEncoding、以及 Java 源文件编码。解决顺序是从下往上查:先SHOW VARIABLES LIKE 'character%'确认数据库是 utf8mb4,再SHOW CREATE TABLE product确认表也是 utf8mb4,然后检查 JDBC URL 有没有characterEncoding=utf8mb4,最后确认 IDE 的源文件编码是 UTF-8。四层都对了就不会乱码。

5.2 连接池耗尽导致页面卡死

现象:系统跑一段时间后所有查询都超时,日志里出现Connection is not available, request timed out。原因是有代码拿了连接没关闭,常见于 try 块里开了 Connection 但 catch 块里忘了 close,或者 ResultSet 和 PreparedStatement 没关。解决方法是全部用 try-with-resources,Connection 的获取和关闭也放进 try-with-resources 里。如果已经上线,先把maximumPoolSize临时调大争取时间,再定位泄漏点。

5.3 金额计算用 double 导致对不上账

现象:采购单明细小计加起来和主表总金额差几分钱。原因是用了 double 或 float 做金额运算,二进制浮点数无法精确表示 0.1 这类小数。解决方法是所有金额字段用BigDecimal,数据库用DECIMAL,Java 里new BigDecimal("0.1")而不是new BigDecimal(0.1)。加法用add(),比较用compareTo()而不是equals(),因为equals会比较精度。

5.4 采购单号重复生成

现象:并发提交时出现两条相同 order_no 的记录,或者插入时报唯一键冲突。原因是单号生成用了System.currentTimeMillis()或者简单的日期加随机数,并发下会撞。解决方法是单号用「日期 + 数据库序列 + 随机后缀」组合,或者直接用数据库自增 ID 加前缀。更稳妥的做法是单独建一张序列表,用UPDATE ... SET seq = seq + 1加行锁取号。

5.5 事务里混用了连接池的不同连接

现象:明明开了事务,回滚却不生效,主表数据还在。原因是事务方法里某一步从连接池又拿了一个新连接,两个连接不在同一个事务里。解决方法是事务方法内所有数据库操作共用同一个 Connection 对象,通过参数传递而不是每个 DAO 方法内部各自DBUtil.getConnection()。如果用了 Spring,@Transactional能帮你管,但纯 JDBC 必须自己传。

6. 从课程设计到可演示系统:三个提升完成度的技巧

第一个技巧是给采购单加一个状态流转图,用枚举类而不是散落的魔法数字。定义OrderStatus枚举,包含 PENDING、APPROVED、STOCKED、CANCELLED 四个值,每个值带 code 和描述。这样代码里写OrderStatus.PENDING.getCode()比写 0 可读得多,答辩时老师一看就知道你懂面向对象。枚举还可以加一个canTransitionTo(OrderStatus target)方法,把状态流转规则收在枚举里,避免业务层到处写 if-else。

第二个技巧是写一个简单的控制台测试类,不依赖前端就能演示完整流程。很多课程设计的前端页面是摆设,答辩时点两下就报错。与其这样,不如写一个Main类,按顺序调用创建供应商、创建商品、创建采购单、审核、入库、查询库存这几个方法,把关键输出打印到控制台。这样即使前端没做完,核心功能也能演示。测试类里用assert或者简单的 if 判断验证结果,比如入库后查库存数量是否等于采购数量。

第三个技巧是给关键操作加日志。不用引入 log4j 那么重,用java.util.logging或者直接System.out.println加时间戳就行。日志要记录操作类型、关键参数、执行结果。比如创建采购单时打印单号和明细条数,入库时打印订单 ID 和影响的库存记录数。出问题时这些日志就是黑匣子,能快速定位是哪一步失败。我一般会在 DAO 层的每个方法入口和出口各打一条,虽然啰嗦但排查时省时间。

最后一个习惯:每次改完 SQL 或 DAO 代码,先跑一遍测试类确认主流程没断,再去看前端。课程设计最怕的是改了一个字段名,前端页面全挂但你没发现。先保核心链路,再保外围展示,这个顺序能让你在截止日期前始终有一个能跑的东西。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询