
简介一份面向数据库课程设计的小型超市管理系统项目后端采用Java技术前端基于Vue框架覆盖页面交互、接口逻辑、数据库设计等完整环节。系统主要功能包括商品管理、进货销售、库存查询、统计报表、会员管理等多个模块经测试运行稳定可复现复刻也能在基础上扩展新功能。资源包内共369个文件压缩包体积约68.62MB主要包含Java后端源码、Vue前端页面、JavaScript逻辑、CSS样式、SVG图标、SQL数据库脚本以及YAML配置等类型其中SQL脚本提供建表语句与初始数据SVG与GIF用于界面元素和操作演示另有设计报告及说明文档辅助理解。目前已有50人学习下载。项目目录结构清晰适合对照部署可参考其架构设计、数据库关系与接口写法来完善自身方案既能支撑课程设计、毕业设计与期末大作业也可借鉴设计文档撰写报告包内图表和说明能帮助梳理业务流程降低上手门槛整体实操价值较高。1. 数据库课设为什么不卡在界面而是卡在表结构A同学花了三天把一个开源的小型超市管理系统跑起来登录、进货、收银都能点结果答辩时老师问了一句“库存表为什么这样设计”他愣了半天最后只能承认是从网上抄的。这几乎是数据库课设最常见的翻车现场——界面做得越热闹数据库设计越容易被追问。因为这个项目的评分核心从来不是按钮多漂亮而是表结构、约束、事务这些看不见的东西能不能自圆其说。这个标题背后的任务很明确做一个能覆盖商品、进货、销售、库存四条主线的管理系统用数据库把业务状态管起来。适合两类人一类是第一次做课设、需要照着能跑的方案走通全流程的另一类是已经有代码但被老师追问设计理由、需要补课补逻辑的。下面这套方案不挑语言数据库用 MySQL前端用 Java Swing 或任何你熟悉的界面框架核心是把表结构和 JDBC 事务讲透。2. 先画清超市的业务线从收银小票反推表结构2.1 为什么一张销售小票能拆出四张表拿到“小型超市管理系统”这个命题第一反应是建一张大表把商品、库存、销售全装进去这是课设最常见的错误。超市的日常业务可以拆成两条线进货线和销售线。销售线从收银小票开始一张小票上有流水号、收银员、多行商品明细、每行的数量和金额最后有一个合计。如果把多行商品塞进一行记录里查询“昨天卖了哪些商品”会变成噩梦。所以要从这张小票反推设计小票头是一张表销售订单小票的每一行是另一张表销售明细商品本身是一张表商品档案商品属于哪个分类再单独抽一张分类表。进货线也是同样的逻辑进货单是主表进货明细是子表供应商独立成表。加上登录需要的用户表和商品分类表核心表就定了用户表、分类表、商品表、供应商表、进货单表、进货明细表、销售单表、销售明细表共八张。这里有个关键设计决策库存字段放在商品表里每次销售和进货直接更新这个字段。虽然真正的 ERP 系统会单独建库存流水表但课设命题是“小型”直接维护库存字段更直观也更容易在答辩时讲清楚。2.2 字段不看多少看能不能回答业务问题每一张表先问自己三个问题这行数据代表什么它和哪些表关联查询时最常按什么条件过滤以商品表为例常见字段为商品ID、条形码、商品名称、分类ID、规格、单位、进货价、销售价、当前库存、库存下限、创建时间。其中“库存下限”是一个容易被忽略但很加分的字段它支撑“库存预警”功能——库存低于下限时在界面上高亮。条形码字段用字符串类型而不是整数因为条形码可能以 0 开头整数类型会丢前导零。主键和关联字段的命名建议统一每张表主键叫 id关联字段写成 xxx_id例如 category_id、supplier_id这样写 JOIN 时一眼能看出关系。金额字段的类型是 DECIMAL(10,2) 而不是 FLOAT 或 DOUBLE这个坑后面专门说。所有表都加一个 create_time 字段DATETIME 类型默认值 CURRENT_TIMESTAMP虽然业务上不一定用到但答辩时老师问“怎么记录数据入库时间”就能顺势展示。2.3 建表 SQL直接把约束写进 DDL别靠程序检查下面这套建表脚本是我一般在课程设计里用的基线版本注意外键、唯一键和默认值都写在 DDL 里不要指望 Java 代码去维护数据完整性。数据库选 MySQL 8.0字符集用 utf8mb4避免中文乱码。CREATE DATABASE supermarket_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE supermarket_db; CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role TINYINT NOT NULL DEFAULT 1 COMMENT 0-管理员 1-收银员, real_name VARCHAR(50), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE category ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE, remark VARCHAR(200) ) ENGINEInnoDB; CREATE TABLE supplier ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(80) NOT NULL, contact VARCHAR(50), phone VARCHAR(20) ) ENGINEInnoDB; CREATE TABLE product ( id INT AUTO_INCREMENT PRIMARY KEY, barcode VARCHAR(30) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, category_id INT NOT NULL, spec VARCHAR(50), unit VARCHAR(10) NOT NULL, purchase_price DECIMAL(10,2) NOT NULL, sale_price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, min_stock INT NOT NULL DEFAULT 10, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_product_category FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINEInnoDB; CREATE TABLE purchase_order ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(30) NOT NULL UNIQUE, supplier_id INT NOT NULL, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_purchase_supplier FOREIGN KEY (supplier_id) REFERENCES supplier(id), CONSTRAINT fk_purchase_user FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINEInnoDB; CREATE TABLE purchase_order_detail ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL, CONSTRAINT fk_pd_order FOREIGN KEY (order_id) REFERENCES purchase_order(id), CONSTRAINT fk_pd_product FOREIGN KEY (product_id) REFERENCES product(id) ) ENGINEInnoDB; CREATE TABLE sale_order ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(30) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_sale_user FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINEInnoDB; CREATE TABLE sale_order_detail ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL, CONSTRAINT fk_sd_order FOREIGN KEY (order_id) REFERENCES sale_order(id), CONSTRAINT fk_sd_product FOREIGN KEY (product_id) REFERENCES product(id) ) ENGINEInnoDB;DDL 里几个约定需要展开说。第一所有表用 InnoDB 引擎因为 InnoDB 支持外键和事务前者保证引用完整性后者保证销售扣库存不会出现“流水记了但库存没减”的中间状态。第二order_no 字段加 UNIQUE 约束防止并发时生成重复的单号。第三金额字段全部是 DECIMAL(10,2)整数部分 8 位、小数 2 位对小型超市绰绰有余。第四外键约束里明细表引用主表删除主表记录时如果有明细引用会报错这其实是保护而不是麻烦。字段类型选型还有一个容易被忽视的点库存字段用 INT不要用 TINYINT因为 TINYINT 上限是 127一件商品进货 200 件就直接溢出报错。密码字段用 VARCHAR(100) 而不是 VARCHAR(32)因为将来如果要存 MD5 加盐后的哈希值32 位不够用。这些细节属于“答辩时老师专门问类型为什么这么选”的高频问题。2.4 范式不背定义看表之间怎么 JOIN建表之后要能解释设计是符合范式的不需要背范式的书面定义用业务逻辑讲清楚就行。用户表、商品表、供应商表是独立的实体不存在重复信息这是第一范式字段原子和第二范式非主键字段依赖完整主键的直观体现。进货明细和销售明细表的主键是自增 id业务字段完全依赖这个主键不依赖别的字段这就是第三范式的意思。真正需要讨论的是“库存放哪里”这个设计决定。把库存字段直接放在商品表里严格来说不是最优设计因为每一次销售和进货都要 UPDATE product 表库存变更是有历史轨迹的比如月底盘点时想查“某商品上周的库存是多少”直接存字段的方案回答不了。但课设命题是小型系统这种设计胜在直观查询商品列表时一条 SQL 就把价格和库存都带出来不用 JOIN 库存流水表。我一般会在设计文档里写明这个取舍库存字段是反规范化设计为了查询性能牺牲了历史追溯能力真正的生产系统会加 inventory_log 表。这样答辩时老师会觉得你想过而不是抄的。3. 用 JDBC 把界面和数据库串起来登录和销售两个模块的完整写法3.1 登录模块PreparedStatement 是底线不是加分项界面框架选什么不影响下面的核心逻辑我一般用 Java Swing 演示因为课设答辩时单机程序演示最省事。登录模块的逻辑很简单拿用户输入的用户名和密码去 user 表比对匹配则放行不匹配则提示。这里唯一的底线是必须用 PreparedStatement不能用 Statement 拼接 SQL。网上很多老教程还在教 Statement 拼接一旦用户名输入 or 11登录直接被绕过这是数据库课设里最常见的代码安全扣分点。public User login(String username, String password) { String sql SELECT id, username, role, real_name FROM user WHERE username ? AND password ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User u new User(); u.setId(rs.getInt(id)); u.setUsername(rs.getString(username)); u.setRole(rs.getInt(role)); u.setRealName(rs.getString(real_name)); return u; } return null; } } catch (SQLException e) { e.printStackTrace(); return null; } }这段代码里有三个容易被忽略的细节。第一SELECT 里只查需要的字段不查 password避免密码在内存里多待一会儿。第二登录时先不校验验证码那些花活把核心的防注入做好。真正要注意的是这个系统的密码是明文存储的这在生产环境是底线问题但课设里为了演示方便很多人就这么做了答辩时被问到就承认并说明生产环境应该存储 MD5 加盐哈希。第三try-with-resources 写法保证 Connection、PreparedStatement、ResultSet 都自动关闭不会把数据库连接耗尽——很多课设程序多开几个窗口就报 Too many connections根源就是连接没关。DBUtil.getConnection() 的工具类写法是另一个高频考察点。常见的写法是把连接信息放到 db.properties 配置文件里而不是硬编码在 Java 类中。配置项包括 url、username、password其中 url 里的 characterEncoding 和 serverTimezone 两个参数是后面避坑章节的主角。加载配置用 java.util.Properties用 ClassLoader 读取 classpath 下的文件这样换数据库环境时不用改代码改配置就行。3.2 销售模块一个事务保证库存扣减和流水写入同时成功销售收银是整个系统里最值得写好的模块因为它涉及两张表的写入和一个库存更新往 sale_order 插一条主单往 sale_order_detail 插一到多行明细再 UPDATE product 表把库存减掉。这三步如果没有事务包裹就会出现库存扣了但流水没写或者流水写了但库存没扣的数据不一致。正确做法是手动控制事务先 setAutoCommit(false)全部成功后 commit任何一步出错 rollback。public void checkout(CheckoutRequest req) throws SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 插入销售主单 String orderNo generateOrderNo(); String insertOrder INSERT INTO sale_order (order_no, user_id, total_amount) VALUES (?, ?, ?); long orderId; try (PreparedStatement ps conn.prepareStatement(insertOrder, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, orderNo); ps.setInt(2, req.getUserId()); ps.setBigDecimal(3, req.getTotalAmount()); ps.executeUpdate(); try (ResultSet keys ps.getGeneratedKeys()) { keys.next(); orderId keys.getLong(1); } } // 2. 插入明细行并更新库存每条明细独立检查库存是否足够 String insertDetail INSERT INTO sale_order_detail (order_id, product_id, quantity, price, amount) VALUES (?, ?, ?, ?, ?); String deductStock UPDATE product SET stock stock - ? WHERE id ? AND stock ?; try (PreparedStatement psDetail conn.prepareStatement(insertDetail); PreparedStatement psStock conn.prepareStatement(deductStock)) { for (OrderItem item : req.getItems()) { psDetail.setLong(1, orderId); psDetail.setInt(2, item.getProductId()); psDetail.setInt(3, item.getQuantity()); psDetail.setBigDecimal(4, item.getPrice()); psDetail.setBigDecimal(5, item.getAmount()); psDetail.addBatch(); psStock.setInt(1, item.getQuantity()); psStock.setInt(2, item.getProductId()); psStock.setInt(3, item.getQuantity()); psStock.executeUpdate(); if (psStock.getUpdateCount() 0) { throw new SQLException(商品ID item.getProductId() 库存不足); } } psDetail.executeBatch(); } conn.commit(); } catch (SQLException e) { if (conn ! null) { conn.rollback(); } throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } }这个方法的几个参数和处理策略值得细看。库存扣减用了条件 UPDATE 写法UPDATE product SET stock stock - ? WHERE id ? AND stock ?这样的一条 SQL 能在数据库层面保证不会扣成负数不用先 SELECT 再判断再 UPDATE。受影响行数为 0 说明库存不足直接抛异常触发回滚。批次插入明细用了 addBatch 和 executeBatch减少数据库往返次数数据量大时性能差异明显量小时也没有坏处。单号生成函数 generateOrderNo 我一般返回时间戳加随机数的组合形如 20250607153012 加三位随机数虽然不保证绝对唯一但配合 sale_order 表 order_no 字段的 UNIQUE 约束插入时如果撞了会报错程序捕获后重试一次即可。真正并发量高的系统会用数据库序列或雪花算法课设里时间戳方案足够。销售模块写完后进货模块的代码结构几乎一样只是把 sale_order 换成 purchase_orderUPDATE 的方向从减库存变成加库存把 DELETE 的坑留给了下一章。4. 数据库课设里的五个高频坑乱码、驱动、外键、金额、事务4.1 中文乱码连接 URL 少一个参数界面上全是问号现象程序启动后下拉框里的商品分类显示为“???”或“浜嬬被”数据库表里中文正常但程序读出来乱码。原因几乎都出在 JDBC 连接 URL 上没有声明字符集或者 MySQL 表本身的字符集是 latin1 而不是 utf8mb4。解决方法是两处一起改建库时明确用 utf8mb4前面 DDL 已经写死连接 URL 里加上 characterEncodingutf8 参数。// db.properties 里建议的最小配置 jdbc.urljdbc:mysql://localhost:3306/supermarket_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.password123456这里注意 characterEncodingutf8 对应 MySQL 的 utf8mb4JDBC 驱动的参数名沿用 utf8 不冲突。检查思路是先用 Navicat 或命令行客户端看表数据是否正常如果客户端正常只有 Java 程序乱码问题一定出在连接参数或 IDE 源文件编码上。还有一个玄学场景Windows 下 Eclipse 或 IDEA 的源文件默认 GBK 编码代码里直接写了中文 SQL 字符串编译后乱码。这种要顺手把 IDE 的项目编码改成 UTF-8再检查一遍。4.2 驱动加载失败ClassNotFoundException 与版本不对现象代码里写了 Class.forName(com.mysql.jdbc.Driver)控制台报 ClassNotFoundException。原因是 MySQL 5.x 和 8.x 的 JDBC 驱动类名不同MySQL 5.7 及以下用 com.mysql.jdbc.DriverMySQL 8.0 改用 com.mysql.cj.jdbc.Driver。很多同学下载了 MySQL 8.0 却沿用老教程的类名自然加载不到。解决方式是确认自己装的 MySQL 版本8.0 用新类名驱动 JAR 也必须是 mysql-connector-java 8.x。顺带一个常见问题驱动 JAR 放在项目里但没按要求加入构建路径编译能过运行报错这是 IDE 配置问题检查 Build Path 是不是包含了 JAR。排查口诀报 ClassNotFoundException 查依赖和类名报 Communications link failure 查网络和端口。MySQL 默认 3306 端口如果被占用或改了端口连接 URL 里的端口号要跟着改。还要注意 MySQL 8.x 默认认证插件是 caching_sha2_password老版本的驱动连接会报 Unable to load authentication plugin解决方法是升级驱动 JAR而不是去改用户的认证插件。4.3 删除分类时外键拦路ERROR 1451 是保护不是故障现象删除一个商品分类程序报 Cannot delete or update a parent row: a foreign key constraint fails。原因在 category 表被 product 表的 category_id 外键引用分类下只要有商品直接删分类就会违反引用完整性。这不是程序写错了而是数据库在干它该干的活——防止出现“商品指向一个不存在的分类”这种孤儿数据。解决思路分两种业务上允许级联删除就加 ON DELETE CASCADE允许先删子表再删主表业务上不允许删除历史数据就采用软删除给 category 表加一个 status 字段删除只是把 status 置为 0查询时默认过滤掉。课设场景下建议用软删除方案。原因很简单一个商品分类被删除时该分类下的商品按业务逻辑也不应该被物理删除商品表和销售明细表里可能还有引用它们的流水记录物理删除会破坏历史销售数据。加一个 status 字段之后删除操作变成 UPDATE category SET status 0 WHERE id ?商品列表查询 JOIN category 时加条件 AND c.status 1既保留了数据又实现了“不见”的效果。这个方案答辩时也更好讲因为能引出“为什么要保留历史数据”的讨论。4.4 金额精度翻车为什么 double 会算出 0.999999现象两件商品价格分别是 19.9 和 3.6合计显示 23.499999999999996。原因很明确FLOAT 和 DOUBLE 是二进制浮点数无法精确保十进制的 0.1 和 0.9运算时产生二进制误差。这不是超市系统的独有问题是所有金额计算场景的通用坑。解决方式是统一用 DECIMAL 类型Java 侧对应 java.math.BigDecimal。前面 DDL 里已经把 price、amount、total_amount 都定义成 DECIMAL(10,2)建表时这一步做对了后面查询展示就不会出精度问题。有一个反向的坑读取 DECIMAL 字段时如果用 rs.getDouble()精度照样丢。正确写法是 rs.getBigDecimal()拿到 BigDecimal 后再运算。Java 侧的加法用 BigDecimal 的 add 方法注意 new BigDecimal(19.9) 传字符串不要传 double否则等于又绕回二进制误差。数据库里如果已经有 DOUBLE 类型的表用 ALTER TABLE 修改字段类型为 DECIMAL(10,2) 可以修复但真正应该做的是建表时就选对类型。4.5 事务忘提交程序里看到的数据Navicat 里查不到现象程序里插入了一条销售单界面上也显示了但打开 Navicat 查 sale_order 表却看不到这条记录重启程序数据也不见了。原因大概率是插入操作没有 commit程序里看到的是当前连接未提交的临时数据连接关闭后回滚数据消失。很多同学没有手动写 setAutoCommit(false)也没有写 commit而是依赖 JDBC 的自动提交模式这种情况下插入应该生效。如果插入不生效而查询能查到其实查到的是自己事务内的数据说明有人手动关掉了自动提交又没提交。排查方法是先看连接 URL 和代码里有没有 setAutoCommit(false) 出现。如果有事务控制但异常路径没滚比如代码里 catch 异常后没调用 rollback也会出现部分数据写入成功。正确做法是统一用前面销售模块的写法try 里 commitcatch 里 rollbackfinally 里恢复自动提交并关闭连接。这条是课设答辩时容易暴露的漏洞因为演示系统的界面掩盖了底层数据不一致老师用 Navicat 随手一查就穿帮。5. 从及格到优秀的三个加分别视图、触发器、定期备份如果核心功能已经跑通数据库设计也讲得清楚但想再往上提一档可以做三个成本低但答辩效果极好的增强。第一个是库存预警视图创建一条视图把库存低于下限的商品筛选出来程序里只需要查询视图就能拿到预警列表不用每次都写 WHERE stock min_stock。这条视图放在 SQL 文件里提交老师打开数据库就能看到比在界面里写死逻辑更有说服力。CREATE VIEW v_stock_warning AS SELECT p.id AS product_id, p.name, p.barcode, p.stock, p.min_stock, c.name AS category_name FROM product p JOIN category c ON p.category_id c.id WHERE p.stock p.min_stock; CREATE TRIGGER trg_sale_detail_after_insert AFTER INSERT ON sale_order_detail FOR EACH ROW INSERT INTO stock_log (product_id, change_type, quantity, create_time) VALUES (NEW.product_id, 1, -NEW.quantity, NOW());第二个增强是触发器加库存变动日志。设计一张 stock_log 表字段为 id、product_id、change_type1销售减库存 2进货加库存 3盘点调整、quantity、create_time用触发器在 sale_order_detail 插入后自动写一条变动日志。这样做的好处是历史库存变动有迹可查正好回应前面“库存字段直接放商品表里没法追溯历史”的短板答辩时可以说我用日志表补上了这个能力。注意触发器里 NEW.quantity 表示新插入行的数量减库存时写成负值存入日志查询时 SUM(quantity) 就是当前库存变化量。第三个增强是演示前用 mysqldump 做一个备份脚本把数据导出为 SQL 文件提交到课设包里。很多评审会看一个项目的工程化程度能一键备份数据库的项目印象分会明显不一样。Windows 下可以用下面的命令放到 bat 文件里mysqldump -uroot -p123456 supermarket_db backup_%date:~0,10%.sql用率相对低因为 MySQL 8.0 的 mysqldump 在 Windows 下对密码带特殊字符的会绕坑我一般建议直接手动用 Navicat 的转储 SQL 文件功能把结构和数据都导出来放进课设压缩包。备份的意义是让项目在另一台电脑上能还原运行演示时老师如果要换机器环境这个 SQL 文件是救命的。我自己的习惯是课设提交前一定做三件事重新用建表 SQL 在空数据库上跑一遍确认 DDL 能完整执行用转储出来的 SQL 文件恢复一个副本确认数据完整再查一遍代码里有没有明文密码以外的安全硬伤。这三个检查做完项目踩坑的概率就低多了。希望帮到你。本文还有配套的精品资源点击获取