简介:这是一份面向Java初学者与课程设计学习者的超市管理系统后端项目,基于Java语言开发,通过JDBC与MySQL数据库交互,实现商品信息、库存与销售记录等数据的存储和管理。项目侧重后端业务逻辑,适合用于巩固面向对象编程、数据库操作与异常处理等核心技能。压缩包共49个文件,约783KB,包含22个java源码、22个class编译文件、1个mysql-connector驱动jar包,以及sql建库脚本和Eclipse工程配置文件,目录按control、dao、po、db、view、main等分层组织,结构清晰,便于理解MVC式分层设计。目前已有1379人学习下载。读者可从中获得一套完整的课设实现方案,涵盖JDBC连接、SQL增删改查、DAO数据访问与业务控制逻辑,并参考其分层目录与数据库脚本快速搭建运行环境,适合作为Java与数据库综合实践的参考范例。
1. 从零手写超市管理系统:为什么课设选它最容易翻车也最容易拿优
每年一到期末,Java 课设选题里「超市管理系统」的出现频率高得离谱。很多人第一反应是去网上找一份现成源码,改个包名、换个数据库密码就交差,结果答辩时被老师问一句「你这个库存扣减为什么不用事务」就当场卡壳。我见过太多这样的场面:代码能跑,但一问就露馅。其实这个题目本身并不难,难的是大多数人把它当成了 CRUD 堆砌,而不是一个需要认真设计的小型业务系统。
这篇文章面向的是正在做 Java 课设、或者想用一个完整项目把 Java 基础、JDBC、Swing 或 Spring Boot 串起来的人。我会从需求拆解讲到数据库设计,再到核心业务代码和常见翻车点,尽量把每一步都写到你能直接照着敲的程度。你不需要事先看过任何源码包,跟着走就行。选超市管理系统作为课设,最大的好处是业务逻辑足够直观——商品、库存、收银、会员,这些概念你天天在超市里见,不用花时间理解需求;最大的坑也在这里,因为直观,所以容易想当然,最后在库存并发、金额精度、事务边界上栽跟头。
2. 需求拆解与数据库设计:先把表结构定死再动手写代码
2.1 超市管理系统到底要管哪几件事
很多人一上来就打开 IDEA 建工程,这是典型的翻车起点。超市管理系统的业务边界其实很清晰,核心就四块:商品管理、库存管理、收银结算、会员管理。商品管理负责商品信息的增删改查,包括条码、名称、分类、进价、售价;库存管理跟踪每个商品的当前库存量和库存流水;收银结算处理一次购物车里多个商品的金额计算、折扣、找零;会员管理维护会员信息和积分。
把这四块画成用例,你会发现它们之间有明确的依赖关系:收银依赖库存扣减,库存依赖商品存在,会员折扣影响收银金额。这个依赖链决定了你的数据库表不能孤立设计,必须考虑外键和事务。我一般会先画一张简单的 ER 图(纸上画就行),把实体和关系理清楚,再动手建表。这一步花半小时,能省掉后面至少两小时的改表时间。
还有一个容易被忽略的点:角色。超市系统至少有两种角色——管理员和收银员。管理员能改商品和价格,收银员只能做收银操作。课设里不一定要做完整的权限框架,但至少要在登录时区分角色,并在关键操作前做校验。这是答辩时老师很喜欢问的点,也是体现你「面向对象编程 Java」思维的地方。
2.2 建表 SQL 与字段类型选择
下面是我常用的建表语句,基于 MySQL 8.0,字段类型的选择都考虑了实际业务场景。你可以直接复制执行,也可以根据自己的需求调整。
-- 商品表:存储商品基础信息 CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '商品ID', barcode VARCHAR(32) NOT NULL UNIQUE COMMENT '条形码', name VARCHAR(128) NOT NULL COMMENT '商品名称', category VARCHAR(64) COMMENT '分类', purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '进价', sale_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '售价', stock INT NOT NULL DEFAULT 0 COMMENT '当前库存', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; -- 库存流水表:记录每一次库存变动 CREATE TABLE stock_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL COMMENT '商品ID', change_type TINYINT NOT NULL COMMENT '1入库 2销售扣减 3退货', quantity INT NOT NULL COMMENT '变动数量,正数增加负数减少', before_stock INT NOT NULL COMMENT '变动前库存', after_stock INT NOT NULL COMMENT '变动后库存', order_id BIGINT COMMENT '关联订单ID', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_product_id (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表'; -- 订单表 CREATE TABLE `order` ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号', member_id BIGINT COMMENT '会员ID,非会员为空', total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总金额', discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '折扣金额', pay_amount DECIMAL(10,2) NOT NULL COMMENT '实付金额', pay_type TINYINT NOT NULL COMMENT '1现金 2微信 3支付宝', cashier_id BIGINT NOT NULL COMMENT '收银员ID', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; -- 订单明细表 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, product_name VARCHAR(128) NOT NULL COMMENT '冗余商品名,防止商品改名后历史订单显示错误', quantity INT NOT NULL, unit_price DECIMAL(10,2) NOT NULL COMMENT '成交单价', subtotal DECIMAL(10,2) NOT NULL COMMENT '小计', INDEX idx_order_id (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表'; -- 会员表 CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) NOT NULL UNIQUE COMMENT '手机号', name VARCHAR(64) COMMENT '姓名', points INT NOT NULL DEFAULT 0 COMMENT '积分', level TINYINT NOT NULL DEFAULT 1 COMMENT '1普通 2银卡 3金卡', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员表';这里有几个参数选择需要说明。金额字段一律用DECIMAL(10,2),绝对不要用FLOAT或DOUBLE,因为浮点数在累加时会出现精度丢失,比如 0.1 + 0.2 不等于 0.3,收银场景下这是致命的。库存字段用INT而不是BIGINT,因为单个超市的商品库存不可能超过 21 亿。订单明细表里冗余了product_name,这是有意为之——如果商品后来改名了,历史订单应该显示当时的名字,而不是被新名字覆盖。
stock_record表的设计是很多人会忽略的。只记录当前库存,出了问题根本查不到是谁什么时候改的。有了流水表,任何库存异常都能追溯。before_stock和after_stock两个字段看起来冗余,但在排查并发问题时非常有用,能直接看出两次操作之间有没有其他操作插入。
2.3 用 MyBatis-Plus 根据实体类反向生成 SQL 的思路
热搜里有人问「mybatisplus 根据 java 实体类生成创建表的 sql 语句」,这个需求在课设里其实挺常见——先写实体类,再倒推建表。MyBatis-Plus 本身不直接提供这个功能,但可以借助它的代码生成器反向配置,或者用 JPA 的ddl-auto先跑一遍。我一般会手写建表 SQL,因为可控性更强,但如果你确实想从实体类生成,可以用下面这个思路:在实体类上加 JPA 注解,然后用 Hibernate 的SchemaExport导出 DDL。
import javax.persistence.*; @Entity @Table(name = "product") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false, length = 32) private String barcode; @Column(nullable = false, length = 128) private String name; @Column(precision = 10, scale = 2) private BigDecimal salePrice; // 省略 getter/setter }然后用几行代码导出建表语句:
import org.hibernate.cfg.Configuration; import org.hibernate.tool.hbm2ddl.SchemaExport; public class DdlGenerator { public static void main(String[] args) { Configuration cfg = new Configuration().configure(); cfg.addAnnotatedClass(Product.class); SchemaExport export = new SchemaExport(cfg); // 第二个参数 true 表示输出到控制台,false 表示不执行到数据库 export.setOutputFile("ddl.sql"); export.create(true, false); } }这个方式适合你已经有实体类、想快速得到建表语句的场景。但要注意,Hibernate 生成的 DDL 不一定符合你的预期,比如索引名、字符集可能和你手写的不同。课设里我建议还是手写 SQL,因为答辩时老师更认可你对表结构的理解,而不是工具生成的产物。
3. 核心业务代码实现:库存扣减、金额计算与事务边界
3.1 库存扣减为什么必须用乐观锁
库存扣减是超市管理系统里最容易出 bug 的地方。假设两个收银员同时卖同一件商品,当前库存是 1,两人都查到库存为 1,都判断「库存足够」,然后都执行UPDATE product SET stock = stock - 1,结果库存变成 -1。这就是典型的并发超卖问题。
解决方式有两种:悲观锁和乐观锁。悲观锁用SELECT ... FOR UPDATE锁住行,简单但性能差,课设里如果并发不高其实也能用。乐观锁用版本号或库存条件更新,性能好,是更常见的做法。我一般用条件更新,SQL 长这样:
UPDATE product SET stock = stock - #{quantity}, updated_at = NOW() WHERE id = #{productId} AND stock >= #{quantity}然后在 Java 里判断受影响行数:
@Mapper public interface ProductMapper { @Update("UPDATE product SET stock = stock - #{quantity} " + "WHERE id = #{productId} AND stock >= #{quantity}") int deductStock(@Param("productId") Long productId, @Param("quantity") Integer quantity); } @Service public class StockService { @Autowired private ProductMapper productMapper; @Transactional(rollbackFor = Exception.class) public void deduct(Long productId, Integer quantity) { int affected = productMapper.deductStock(productId, quantity); if (affected == 0) { throw new BusinessException("库存不足或商品不存在"); } } }这段代码的关键在于WHERE stock >= #{quantity}这个条件。它把「判断库存是否足够」和「扣减库存」合并成了一条原子 SQL,数据库层面保证了不会超卖。如果受影响行数为 0,说明库存不够或者商品不存在,直接抛异常回滚。@Transactional注解保证了这个操作在事务里执行,配合rollbackFor = Exception.class确保任何异常都会回滚。
参数说明:productId是商品主键,quantity是购买数量。注意quantity必须是正数,如果是退货入库,应该走单独的入库方法,不要用负数来复用这个方法,否则条件判断会出问题。
3.2 收银结算的金额计算与 BigDecimal 的正确用法
收银结算涉及多个商品的金额累加、会员折扣、找零计算。这里最大的坑是金额精度。前面说了数据库用DECIMAL,Java 里对应的是BigDecimal。但BigDecimal用不对照样出问题,比如new BigDecimal(0.1)会得到一个近似值,必须用new BigDecimal("0.1")或者BigDecimal.valueOf(0.1)。
下面是一个收银结算的核心方法:
@Service public class CheckoutService { @Autowired private ProductMapper productMapper; @Autowired private OrderMapper orderMapper; @Autowired private OrderItemMapper orderItemMapper; @Autowired private StockService stockService; @Transactional(rollbackFor = Exception.class) public Order checkout(List<CartItem> cartItems, Long memberId, Integer payType, Long cashierId) { // 1. 计算总金额 BigDecimal totalAmount = BigDecimal.ZERO; for (CartItem item : cartItems) { Product product = productMapper.selectById(item.getProductId()); if (product == null || product.getStatus() == 0) { throw new BusinessException("商品不存在或已下架"); } BigDecimal subtotal = product.getSalePrice() .multiply(BigDecimal.valueOf(item.getQuantity())); totalAmount = totalAmount.add(subtotal); } // 2. 计算会员折扣 BigDecimal discountAmount = BigDecimal.ZERO; if (memberId != null) { Member member = memberMapper.selectById(memberId); if (member != null && member.getLevel() == 3) { // 金卡会员打 9 折 discountAmount = totalAmount.multiply(new BigDecimal("0.1")) .setScale(2, RoundingMode.HALF_UP); } } BigDecimal payAmount = totalAmount.subtract(discountAmount); // 3. 扣减库存(逐个扣减,任何一个失败都回滚) for (CartItem item : cartItems) { stockService.deduct(item.getProductId(), item.getQuantity()); } // 4. 写入订单和明细 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setMemberId(memberId); order.setTotalAmount(totalAmount); order.setDiscountAmount(discountAmount); order.setPayAmount(payAmount); order.setPayType(payType); order.setCashierId(cashierId); orderMapper.insert(order); for (CartItem item : cartItems) { Product product = productMapper.selectById(item.getProductId()); OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setProductId(item.getProductId()); oi.setProductName(product.getName()); oi.setQuantity(item.getQuantity()); oi.setUnitPrice(product.getSalePrice()); oi.setSubtotal(product.getSalePrice() .multiply(BigDecimal.valueOf(item.getQuantity()))); orderItemMapper.insert(oi); } return order; } }这段代码有几个关键点。第一,金额计算全程用BigDecimal,乘法用multiply,加法用add,减法用subtract。第二,折扣计算后调用了setScale(2, RoundingMode.HALF_UP),保留两位小数并四舍五入,这是金额计算的标准做法。第三,库存扣减放在订单写入之前,任何一个商品扣减失败都会抛异常,整个事务回滚,不会出现「订单写了但库存没扣」的情况。第四,订单明细里冗余了productName和unitPrice,保证历史订单不受商品后续修改的影响。
generateOrderNo()方法我一般用时间戳加随机数,比如System.currentTimeMillis() + String.format("%04d", random.nextInt(10000)),课设里够用了。生产环境会用雪花算法,但没必要在课设里引入。
3.3 事务边界怎么划才不出错
事务边界划错是课设里第二常见的翻车点。上面checkout方法加了@Transactional,但如果你在同类里调用另一个@Transactional方法,Spring 的代理机制会导致事务不生效。比如你在CheckoutService里直接调this.deduct(),deduct上的事务注解会被忽略。
解决办法是把库存扣减放到单独的StockService里,通过注入的方式调用,就像上面代码那样。这样两个事务方法都经过 Spring 代理,事务才能正常生效。这是 Spring AOP 的经典坑,答辩时如果能主动说出来,老师会对你刮目相看。
另一个注意点是事务的粒度。不要把整个收银流程拆成多个小事务,否则库存扣了但订单没写,数据就不一致了。也不要把无关操作(比如写日志)放进事务里,会拉长事务持有时间。我一般的原则是:一个业务用例一个事务,事务里只放必须原子执行的数据库操作。
4. 避坑与排查:课设答辩前必须自己先过一遍的 5 个问题
4.1 库存扣成负数,但代码里明明判断了
现象:数据库里出现stock = -1的记录,但代码里写了if (stock >= quantity)的判断。
原因:判断和扣减是两条 SQL,中间有时间窗口。两个线程同时查到库存为 1,都判断通过,然后都执行扣减。
解决:把判断和扣减合并成一条 SQL,用UPDATE ... WHERE stock >= #{quantity},根据受影响行数判断是否成功。这就是 3.1 节讲的乐观锁方案。
4.2 金额出现 0.30000000000000004
现象:收银小票上打印出0.30000000000000004这样的金额。
原因:用了double或float做金额计算,浮点数精度丢失。
解决:所有金额字段用BigDecimal,数据库用DECIMAL(10,2),Java 里用new BigDecimal("0.1")而不是new BigDecimal(0.1)。计算后统一setScale(2, RoundingMode.HALF_UP)。
4.3 订单写入了但库存没扣
现象:数据库里有订单记录,但对应商品的库存没有变化。
原因:库存扣减和订单写入不在同一个事务里,或者库存扣减抛了异常但被 catch 了没有重新抛出。
解决:确保两个操作在同一个@Transactional方法里,库存扣减失败时抛RuntimeException,不要吞异常。如果用了 try-catch,记得在 catch 里throw new BusinessException(...)。
4.4 商品改名后历史订单显示新名字
现象:把「可乐」改名为「百事可乐」后,之前的订单明细里也变成了「百事可乐」。
原因:订单明细表只存了product_id,查询时 join 商品表取当前名称。
解决:订单明细表冗余product_name和unit_price,写入订单时把当时的名称和单价存进去,查询时直接用冗余字段,不 join 商品表。
4.5 并发收银时订单号重复
现象:两个收银员同时结账,生成了相同的订单号,插入时报唯一键冲突。
原因:订单号生成用了时间戳,但两个请求在同一毫秒内到达。
解决:订单号加随机数或序列号,比如时间戳 + 收银员ID + 随机4位。更稳妥的做法是用数据库自增ID加前缀,或者用 UUID。课设里时间戳加随机数基本够用,但要知道这个边界。
5. 从课设到能拿出手的项目:三个进阶技巧和验证方法
5.1 用单元测试验证库存扣减的并发安全
课设答辩时,如果你能现场跑一个并发测试证明库存不会超卖,说服力会强很多。下面是一个用CountDownLatch模拟并发扣减的测试:
@SpringBootTest public class StockConcurrencyTest { @Autowired private StockService stockService; @Autowired private ProductMapper productMapper; @Test public void testConcurrentDeduct() throws InterruptedException { // 准备商品,库存 10 Product product = new Product(); product.setBarcode("TEST001"); product.setName("测试商品"); product.setStock(10); product.setSalePrice(new BigDecimal("9.90")); productMapper.insert(product); int threads = 20; CountDownLatch latch = new CountDownLatch(threads); ExecutorService pool = Executors.newFixedThreadPool(threads); for (int i = 0; i < threads; i++) { pool.submit(() -> { try { stockService.deduct(product.getId(), 1); } catch (Exception e) { // 库存不足的异常,预期内 } finally { latch.countDown(); } }); } latch.await(); pool.shutdown(); Product after = productMapper.selectById(product.getId()); // 库存应该正好为 0,不会变成负数 assertEquals(0, after.getStock()); } }这个测试起 20 个线程同时扣减库存为 10 的商品,每个线程扣 1。如果并发控制正确,最终库存应该是 0,成功扣减 10 次,失败 10 次。如果库存变成负数,说明乐观锁没生效。这个测试跑通,答辩时可以直接展示,比口头说「我做了并发控制」有力得多。
5.2 用 AOP 记录操作日志,方便排查问题
课设里加一个简单的 AOP 日志,记录谁在什么时候做了什么操作,不仅方便自己排查,也是加分项。定义一个注解@LogRecord,然后在切面里拦截:
@Aspect @Component public class LogAspect { @Around("@annotation(logRecord)") public Object around(ProceedingJoinPoint joinPoint, LogRecord logRecord) throws Throwable { long start = System.currentTimeMillis(); Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; // 实际课设里可以写入数据库或日志文件 System.out.println(String.format("[操作日志] 方法=%s, 耗时=%dms, 描述=%s", joinPoint.getSignature().getName(), cost, logRecord.value())); return result; } }这个切面会在标注了@LogRecord的方法执行前后记录耗时和描述。课设里不用做太复杂,能体现你了解 AOP 就行。注意日志不要写进事务里,避免拉长事务时间。
5.3 一个我踩过的坑:别在实体类里用 Lombok 的 @Data 配 JPA
最后说一个血泪经验。如果你用 JPA 做 ORM,实体类上不要同时用 Lombok 的@Data和 JPA 的@Entity。@Data会自动生成equals和hashCode,而 JPA 实体在持久化上下文里可能处于不同状态,自动生成的equals会导致一些诡异的问题,比如Set里去重失效、缓存命中异常。我一般用@Getter和@Setter代替@Data,需要toString就手动写一个排除关联字段的版本。
这个坑在课设里不一定马上暴露,但如果你后面想把这个项目扩展成更完整的系统,迟早会撞上。提前避开,省得以后花时间排查。
希望帮到你。
本文还有配套的精品资源,点击获取