恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Java采购管理系统实战:从数据库设计到事务一致性
首页
资讯中心
/
Java采购管理系统实战:从数据库设计到事务一致性
Java采购管理系统实战:从数据库设计到事务一致性
发布时间:2026/9/29 0:03:21
简介这是一套面向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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 商品表 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 采购单主表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 采购单明细表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 库存表 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段 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 groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId version5.1.0/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.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?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4); 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 驱动会报时区错误。characterEncodingutf8mb4和建表字符集对应。maxLifetime要比数据库的wait_timeout小否则连接被服务端断开后池里还留着死连接。3.2 采购单新增主表加明细的事务写法采购单新增是整个系统里最需要保证数据一致性的操作。主表插一条、明细表插 N 条任何一步失败都必须回滚。下面是一个不依赖 Spring 的纯 JDBC 实现public class PurchaseOrderDao { public long createOrder(PurchaseOrder order, ListPurchaseOrderItem 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 ListPurchaseOrder queryOrders(Long supplierId, Integer status, int page, int size) throws SQLException { StringBuilder sql new StringBuilder(SELECT * FROM purchase_order WHERE 11); ListObject 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); ListPurchaseOrder 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 11是个惯用技巧让后续每个条件都能以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. 更新单状态带 status1 条件防止重复入库 try (PreparedStatement ps conn.prepareStatement(updateOrder)) { ps.setLong(1, orderId); int affected ps.executeUpdate(); if (affected 0) { throw new SQLException(订单状态不允许入库或已入库); } } // 2. 查明细 Listlong[] 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条件后如果订单已经是状态 2executeUpdate返回 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 有没有characterEncodingutf8mb4最后确认 IDE 的源文件编码是 UTF-8。四层都对了就不会乱码。5.2 连接池耗尽导致页面卡死现象系统跑一段时间后所有查询都超时日志里出现Connection is not available, request timed out。原因是有代码拿了连接没关闭常见于 try 块里开了 Connection 但 catch 块里忘了 close或者 ResultSet 和 PreparedStatement 没关。解决方法是全部用 try-with-resourcesConnection 的获取和关闭也放进 try-with-resources 里。如果已经上线先把maximumPoolSize临时调大争取时间再定位泄漏点。5.3 金额计算用 double 导致对不上账现象采购单明细小计加起来和主表总金额差几分钱。原因是用了 double 或 float 做金额运算二进制浮点数无法精确表示 0.1 这类小数。解决方法是所有金额字段用BigDecimal数据库用DECIMALJava 里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()。如果用了 SpringTransactional能帮你管但纯 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 代码先跑一遍测试类确认主流程没断再去看前端。课程设计最怕的是改了一个字段名前端页面全挂但你没发现。先保核心链路再保外围展示这个顺序能让你在截止日期前始终有一个能跑的东西。希望帮到你。本文还有配套的精品资源点击获取