
简介一套面向高校数据库课程设计、毕业设计及期末大作业的小型超市管理系统完整工程采用前端Vue、后端Java与数据库SQL三层结构适合需要直接运行项目、完成课设答辩或借鉴设计报告的学生和初级开发者。压缩包共369个文件大小约68.62MB主要包含SVG图标、Java与Vue源码、JavaScript交互逻辑、CSS/SCSS样式、XML配置、SQL数据库脚本、说明文档及少量可执行文件目录按前端、后端、配置和文档划分便于定位与学习。目前已有50人学习浏览工程经过严格测试运行功能稳定可复现复刻。除完整源码和工程文件外还附有设计报告参考、环境配置与运行说明在此基础上可继续扩展进销存、会员管理、库存预警等模块也能按自身需求修改页面与接口快速形成新的课程项目。整体结构清晰适合答辩前梳理项目逻辑也适合练手与二次开发。1. 「数据库课设--小型超市管理系统.zip」为什么说 zip 既是交付物也是一道验收题“数据库课设--小型超市管理系统.zip”这个文件名做数据库课程设计的人十有八九见过老师给的主题不是超市就是图书最后作业一律压缩成一个 zip 交上去。可真正拉开分数差距的往往不是代码量而是交完以后的事——换一台电脑能不能照着 README 把 MySQL 建起来打开登录页做一次完整的进销存操作再跑出两张像样的报表。这篇文章把这个标题拆开讲小型超市管理系统到底要建哪几张表、数据库增删改查怎么写、收银结账为什么必须是事务、换机就跑不起来的坑在哪以及怎样用初始化数据和报表让答辩更有底气。适合正要交数据库课设的人也适合拿到同类 zip 想改成自己项目的同学。2. 小型超市管理系统怎么设计表从收银小票反推出 6 张核心表很多课设翻车不是代码写崩而是表建得不对。一上来就写界面写到一半发现缺字段、缺关联再回头改表后面所有 SQL 都得跟着改。我的习惯是先拿一张收银小票把业务里必须出现的信息全部列出来再决定建哪几张表。2.1 从业务流程反推表结构商品、供应商、库存、销售单的关联方式超市收银小票上有什么商品名、条码、单价、数量、小计、合计、收银员。再往上游想一步商品是谁供的每天营业结束为什么要盘点因为库存和账面要对得上。把这些业务动作拆成实体一个小型超市管理系统最少有这六张表supplier供应商表保存供应商名称、联系人、电话。product商品表保存条码、名称、规格、单位、售价、成本价、供应商外键。stock库存表保存每个商品当前剩余数量。sale_order销售单表保存每一笔收银的金额、收银员、销售时间。sale_item销售明细表保存一笔销售单里每个商品的单价、数量和小计。sys_user用户表保存收银员和管理员账号。为什么要单独建库存表而不是直接在 product 里放一个 stock_quantity 字段这不是考试教条。你进货、退货、盘点、报损都需要记录而 product 里的字段维护的是商品主数据库存是随时间变化的状态。如果混在一起每次改库存都要去 update product商品基本信息和库存变动就耦合了。课设阶段不必做完整的库存流水表但至少把 stock 独立出来后面做报表和并发扣库存都会顺手很多。sale_order 和 sale_item 为什么要拆两张表因为一张销售单可能包含多个商品。如果把商品信息直接堆在订单表里字段没法设计如果只存一个拼接字符串老师一句“怎么统计销量 TOP10”就能把你问住。拆成主表和明细表是经典的父子表结构也是后面写统计 SQL 的基础。2.2 最小可用建表脚本主键、外键、默认值、唯一索引一次设对以 MySQL 8.0 为例下面是能直接抄的建表脚本。之所以说“最小可用”是因为它没有堆砌字段但覆盖了主键、外键、唯一约束、默认值和更新时间这些数据库课设必须展示的点。CREATE DATABASE IF NOT EXISTS supermarket DEFAULT CHARSET utf8mb4; USE supermarket; -- 供应商表 CREATE TABLE supplier ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 供应商ID, name VARCHAR(64) NOT NULL COMMENT 供应商名称, contact VARCHAR(32) NOT NULL DEFAULT COMMENT 联系人, phone VARCHAR(20) NOT NULL DEFAULT COMMENT 联系电话, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT供应商表; -- 商品表 CREATE TABLE product ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 商品ID, barcode VARCHAR(32) NOT NULL COMMENT 商品条码, name VARCHAR(64) NOT NULL COMMENT 商品名称, spec VARCHAR(64) NOT NULL DEFAULT COMMENT 规格如 500ml, unit VARCHAR(8) NOT NULL DEFAULT 件 COMMENT 销售单位, sale_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 售价, cost_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 成本价, supplier_id INT UNSIGNED DEFAULT NULL COMMENT 供应商ID允许为空, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架0停用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_barcode (barcode), KEY idx_supplier (supplier_id), CONSTRAINT fk_product_supplier FOREIGN KEY (supplier_id) REFERENCES supplier (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; -- 库存表 CREATE TABLE stock ( product_id INT UNSIGNED PRIMARY KEY COMMENT 商品ID, quantity INT NOT NULL DEFAULT 0 COMMENT 当前库存数量, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_stock_product FOREIGN KEY (product_id) REFERENCES product (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存表; -- 销售单表 CREATE TABLE sale_order ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 销售单ID, order_no VARCHAR(32) NOT NULL COMMENT 销售单号, operator_id INT UNSIGNED NOT NULL COMMENT 收银员ID, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 应收金额, paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 实收金额, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常0作废, sale_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 销售时间, UNIQUE KEY uk_order_no (order_no), KEY idx_operator (operator_id), KEY idx_sale_time (sale_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT销售单表; -- 销售明细表 CREATE TABLE sale_item ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 明细ID, order_id BIGINT UNSIGNED NOT NULL COMMENT 销售单ID, product_id INT UNSIGNED NOT NULL COMMENT 商品ID, quantity INT NOT NULL COMMENT 销售数量, unit_price DECIMAL(10,2) NOT NULL COMMENT 成交单价, subtotal DECIMAL(10,2) NOT NULL COMMENT 小计金额, KEY idx_order (order_id), CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES sale_order (id), CONSTRAINT fk_item_product FOREIGN KEY (product_id) REFERENCES product (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT销售明细表; -- 用户表 CREATE TABLE sys_user ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 用户ID, username VARCHAR(32) NOT NULL COMMENT 登录名, password VARCHAR(128) NOT NULL COMMENT 密码哈希值不要存明文, real_name VARCHAR(32) NOT NULL DEFAULT COMMENT 姓名, role TINYINT NOT NULL DEFAULT 2 COMMENT 1管理员2收银员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用0停用, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;这套脚本有几个容易被忽视但很关键的设定。金额字段全部用 DECIMAL(10,2)不用 float 或 double因为浮点数在累加和比较时会产生 0.1 0.2 不等于 0.3 这类问题课设数据量小看不出答辩时老师只要把两个金额一减就能挑出错。barcode 加 UNIQUE KEY是为了防止同一条码重复录入。allow null 的 supplier_id 表示商品暂时可以不关联供应商这种设计在初期导入商品数据时非常有用。sale_order 用 BIGINT是因为订单量可能很快超过 INT 上限虽然课设不会真有这么多数据但这是让老师觉得你“知道为什么”的细节。2.3 用 ER 图自查外键该不该有、金额字段该用什么类型表建完不要急着写代码先做一次 ER 图自查。你用什么工具画都行常见的是 MySQL Workbench 的逆向工程或者 dbx 数据库工具直接把刚才的 SQL 导进去生成关系图。自查按这几条来第一每个表必须有主键且主键最好是自增整数不要用业务字段当主键第二外键只建立在确实有父子关系的表之间例如 sale_item 和 sale_order、sale_item 和 product而 supplier 和 product 之间外键可以允许为空第三凡是钱一律 DECIMAL凡是日期能用 DATETIME 就不用 VARCHAR哪怕有人图省事把时间存成字符串排序会乱。第四索引不要乱加除了主键和唯一键给外键列和查询条件列加普通索引就够了比如 sale_time因为报表经常按时间聚合。这四条过下来基本能挡住开学第一周最容易犯的错。记住一个原则表结构是地基代码写得再花哨表不合理后面所有 SQL 都会写得很别扭。3. 用连接池和参数化 SQL 把「增删改查」做成能交付的代码表设计好下一步就是数据库访问层。很多课设教程喜欢在每个按钮事件里 new 一个 Connection用完再关。这个写法在演示时没问题但并发稍微高一点数据库连接就会耗尽。更关键的是如果 SQL 靠字符串拼接随便一个带引号的商品名就能把你整条 SQL 搞崩。3.1 连数据库不是 new 一个 Connection 就完事课设也要用连接池先纠正一个观念本地课设并不需要研究数据库同步软件你只需要老老实实把连接池配好。数据库同步解决的是多机数据一致性问题而课程设计几乎都是单机、单库用不上。单库里最值得花时间的是连接复用。用 Python 做课设的话常见组合是 pymysql 加 DBUtils 的 PooledDB。下面是最小配置import pymysql from dbutils.pooled_db import PooledDB pool PooledDB( creatorpymysql, # 使用的数据库驱动 maxconnections20, # 连接池最大连接数 mincached2, # 启动时预先创建的空闲连接数 maxcached5, # 最多保持的空闲连接数 blockingTrue, # 无空闲连接时阻塞等待而不是直接报错 ping1, # 每次取连接前执行一次探活 host127.0.0.1, port3306, usersale_user, password123456, databasesupermarket, charsetutf8mb4 )参数说明先说三个maxconnections 是连接池能创建的上限课设项目给 10 到 20 都够mincached 是启动时预建两条避免第一次点击按钮时卡顿ping1 表示从池里拿出来的连接先验证一下是否还活着因为 MySQL 的 wait_timeout 可能把长时间空闲连接断开不探活的话你点第一次查询就会报 connection is closed。如果你用 Java思路一样只是框架替你做了一大半。以 Spring Boot 为例HikariCP 默认就在依赖里yml 里改几个参数即可spring: datasource: url: jdbc:mysql://localhost:3306/supermarket?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8 username: sale_user password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 2 connection-timeout: 30000这里常见的一个坑是 JDBC URL 里的 serverTimezone 不写数据库在非默认时区时驱动会报一串看不懂的时区错误。这个放进参数说明里后面避坑章节还会展开。3.2 最小 DAO 层参数化查询、结果映射和增删改查的写法连接池只是第一步真正体现数据库功底的是 DAO 层的写法。核心就一句话永远用参数占位永远不要拼 SQL 字符串。下面是一个查询商品信息的函数同时关联库存表def get_product_by_barcode(barcode: str): sql SELECT p.id, p.name, p.sale_price, s.quantity FROM product p LEFT JOIN stock s ON s.product_id p.id WHERE p.barcode %s AND p.status 1 with pool.connection() as conn: with conn.cursor(cursorpymysql.cursors.DictCursor) as cur: cur.execute(sql, (barcode,)) return cur.fetchone()这里的 %s 是参数占位符不需要给 barcode 加引号pymysql 会帮你安全地转义。这个细节就是数据库增删改查里最容易翻车的“引号地狱”。用 format 或者 f-string 拼接的话用户只要输入一个单引号SQL 结构就被破坏了参数化以后这种注入彻底消失了。LEFT JOIN 也是有讲究的如果某个商品还没建库存记录INNER JOIN 会直接查不到这个商品而 LEFT JOIN 让库存字段为 NULL至少商品还能显示出来。增加和修改的写法同样要参数化def add_product(prod: dict): sql INSERT INTO product (barcode, name, spec, unit, sale_price, cost_price, supplier_id) VALUES (%s, %s, %s, %s, %s, %s, %s) with pool.connection() as conn: with conn.cursor() as cur: cur.execute(sql, ( prod[barcode], prod[name], prod[spec], prod[unit], prod[sale_price], prod[cost_price], prod.get(supplier_id, None), )) conn.commit() return cur.lastrowid这里 conn.commit() 是必须的。pymysql 默认不开启自动提交如果你只 execute 不 commit数据不会真正写入。连接池的 with 语句负责把连接归还池子但不负责提交事务整个写操作的生命周期需要在代码里明确控制。返回的 lastrowid 是插入后的自增主键后面新增库存记录会用到它。3.3 收银结账必须是一个事务扣库存和写销售单一起成功一起失败收银是超市系统里最典型、也最应该展示事务能力的场景。一次扫码结账要做三件事插入 sale_order 主单、插入 sale_item 明细、扣减 stock 库存。这三条 SQL 必须是一个整体任何一个失败前面写入的数据都要撤销否则就会出现订单记录存在但库存没扣或者库存扣了但订单丢了。下面是一段精简的结账事务def create_sale(order_no, operator_id, items): total_amount sum(item[subtotal] for item in items) conn pool.connection() try: with conn.cursor() as cur: # 1. 写入销售单主表 cur.execute( INSERT INTO sale_order (order_no, operator_id, total_amount, paid_amount) VALUES (%s, %s, %s, %s), (order_no, operator_id, total_amount, total_amount) ) order_id cur.lastrowid # 2. 写明细并扣库存 for item in items: cur.execute( INSERT INTO sale_item (order_id, product_id, quantity, unit_price, subtotal) VALUES (%s, %s, %s, %s, %s), (order_id, item[product_id], item[quantity], item[price], item[subtotal]) ) cur.execute( UPDATE stock SET quantity quantity - %s WHERE product_id %s AND quantity %s, (item[quantity], item[product_id], item[quantity]) ) if cur.rowcount 0: raise RuntimeError(f商品 {item[product_id]} 库存不足) conn.commit() except Exception: conn.rollback() raise finally: conn.close()这段代码最值得讲的是扣库存那条 SQL。它不是先 SELECT 查库存再判断够不够最后 UPDATE而是直接在 UPDATE 的 WHERE 条件里写上 quantity 0。这样做有两个好处第一避免两条 SQL 之间出现时间差别人在这中间改了库存你查到的数字已经过期第二MySQL 在执行 UPDATE 时会对命中行加锁直接更新就自动把并发问题挡在了外面。这也是数据库并发锁在课设里的最小应用——不需要手动查锁、不需要解释 MVCC一句 WHERE 条件就能演示什么是“条件更新”。如果老师追问超卖怎么办你可以说这是天然的行锁保护加上事务回滚库存和订单永远保持一致。扣库存后还要检查 rowcount 是否为 0因为 WHERE 条件不满足时 UPDATE 影响 0 行代表库存不足或商品不存在。这里主动抛异常事务就会回滚sale_order 里刚插入的订单也会撤销不会留下“买了但没货”的脏数据。4. 换台电脑就跑不起来小型超市系统交付前的 5 个避坑点课设 zip 交上去最怕的不是功能少而是环境对不上。我见过太多小组在我电脑上跑得好好的拿到机房机器上一开全是红字。下面这五个坑我基本每个都踩过一遍按现象、原因、解决写清楚。4.1 环境坑驱动版本、认证插件、字符集和时区坑一是 MySQL 8.0 认证插件不兼容。现象是代码在本地连库正常换到另一台机器用同样的账号密码启动时却报 Public Key Retrieval is not allowed或者 Access denied for user。原因是 MySQL 8.0 默认认证插件是 caching_sha2_password而你项目里用的连接驱动是 5.x 的老版本两边协议对不上。解决方式是升级驱动Java 换成 mysql-connector-java 8.0.33 这类新版本JDBC URL 里加 allowPublicKeyRetrievaltrue 和 useSSLfalse如果你是手动建的数据库用户也可以把它改回老插件ALTER USER sale_user% IDENTIFIED WITH mysql_native_password BY 123456; FLUSH PRIVILEGES;这行 SQL 在答辩现场经常能救你一命前提是你提前知道这个问题。另外如果你下载的是免安装版的 MySQL zip 而不是安装包记得初始化 data 目录否则 MySQL 服务根本起不来。坑二是中文乱码。现象是插入的商品名变成问号或者控制台输出乱码。原因通常有三个层次叠加建库时字符集不是 utf8mb4、客户端连接时没有指定 charset、SQL 脚本文件本身被系统存成了 GBK。解决方式也分三层建库语句里写 DEFAULT CHARSET utf8mb4连接字符串或 PooledDB 参数里写 charsetutf8mb4SQL 文件用编辑器另存为 UTF-8 无 BOM。如果脚本已经在库里跑了乱码已经进去补救办法是在导入前执行一句 SET NAMES utf8mb4再重新跑一遍数据。坑三是时区导致的时间字段错乱。现象是 JDBC 启动时直接报错最后一行写着 The server time zone value is unrecognized。原因是 MySQL 的时区信息不完整或者驱动不知道当前会话该用哪个时区。解决是在 JDBC URL 后面加 serverTimezoneAsia/Shanghai。Python 的 pymysql 一般不强制要求时区但连接参数里最好也写上 init_commandSET time_zone 08:00保证 DATETIME 写入和读取都是同一套时间基准。4.2 数据坑外键冲突和 SQL 脚本版本差异坑四是外键约束导致数据删不掉。现象是想在商品管理页面删一条商品程序直接抛 Cannot delete or update a parent row。原因是 sale_item 或 stock 里已经存在引用这个商品 ID 的记录外键约束不允许你物理删除仍被引用的父表记录。解决不是取消外键而是用“停用”代替删除执行 UPDATE product SET status 0 WHERE id ?界面不展示停用商品但历史销售数据不会被破坏。这个处理方式在答辩时非常加分因为它体现了你对数据一致性的理解业务数据是历史事实不能随便抹掉。坑五是 SQL 脚本版本差异导致导入失败。现象是本地 MySQL 8.0 写好的脚本拿到 MySQL 5.7 的机器上导入报语法错误有时是 WITH 开头的递归 CTE有时是开窗函数有时是 CHECK 约束。原因是 5.7 和 8.0 在 SQL 语法上不兼容。解决的办法有三个选一个第一在 README 里写清楚“需要 MySQL 8.0 及以上”第二把高级语法改成兼容写法比如用子查询代替开窗函数第三干脆在交付物里放一个 docker-compose让评审电脑用你锁定的 MySQL 版本这个具体做法最后一章讲。还有一个相关的小问题如果你用 IDEA 的数据库工具导出 SQL 脚本它会默认带上很多反引号和注释跨版本导入时更容易报错交付前最好手动在命令行 source 一遍确认脚本能完整执行。另外很多同学喜欢用 Excel 整理商品资料再批量导入数据库。这里有个隐性坑Excel 会把条码这种长数字自动转成科学计数法导入数据库后末几位变成 0联想不到商品。解决是在 Excel 里先把条码列设置为文本再保存为 CSV导入时用 LOAD DATA 或脚本循环并且先导三条做验证别一上来全量跑。这一步做好了能省下大量对账的功夫。5. 从「能跑」到「能答辩」初始化数据、销售报表、角色权限怎么补功能跑通只是及格线。数据库课设的答辩老师看重的不是你会不会写页面而是你能不能拿出“有意义的数据”。一个空库、一张空白登录页只能证明你会连接数据库有初始化数据、能跑统计报表、有角色区分才像一个能用的管理系统。5.1 用递归 CTE 生成 50 条商品和一段时间的销售模拟数据手工往数据库里录 50 条商品太慢而且毫无意义。用一句 SQL 批量生成测试数据是更高效的做法。MySQL 8.0 支持递归 CTE写起来非常简洁-- 生成 50 条商品测试数据 INSERT INTO product (barcode, name, spec, unit, sale_price, cost_price, supplier_id, status) WITH RECURSIVE seq AS ( SELECT 1 AS n UNION ALL SELECT n 1 FROM seq WHERE n 50 ) SELECT CONCAT(69, LPAD(n, 10, 0)) AS barcode, CONCAT(测试商品, n) AS name, 500ml AS spec, 瓶 AS unit, ROUND(2 (n % 30) RAND(), 2) AS sale_price, ROUND(1 (n % 20) RAND(), 2) AS cost_price, (n % 5) 1 AS supplier_id, 1 AS status FROM seq;这段 SQL 的思路是先用递归 CTE 造一张数字临时表从 1 到 50再把数字加工成商品信息。LPAD(n, 10, 0) 保证条码是等长的 13 位取模运算符模拟不同供应商和价格区间RAND() 让价格有波动。如果你用的数据库是 MySQL 5.7没有 CTE那可以改用存储过程循环或者老老实实用一条一条的 INSERT 拼数据效果一样只是脚本长一点。销售数据也可以模拟生成。最简单的方式是写一个存储过程循环插入 500 张销售单DELIMITER // CREATE PROCEDURE gen_sales(IN total INT) BEGIN DECLARE i INT DEFAULT 1; WHILE i total DO INSERT INTO sale_order (order_no, operator_id, total_amount, paid_amount, sale_time) VALUES ( CONCAT(SO, DATE_FORMAT(NOW(), %Y%m%d), LPAD(i, 6, 0)), 1, 10 (i % 100), 10 (i % 100), NOW() - INTERVAL (i % 30) DAY ); SET i i 1; END WHILE; END// DELIMITER ; CALL gen_sales(500);模拟数据不求每个订单都精确只要 sale_time 分散在过去 30 天、金额有起伏报表就能看出趋势。如果有余力再从 sale_order 里随机关联几个商品插入 sale_item这样明细表和主表都有数据答辩时讲 JOIN 查询才有素材。5.2 答辩必问的 3 条 SQL日销售额、滞销商品、销量 TOP10老师看到有数据之后接下来大概率会问“这系统能不能看报表”。下面三条 SQL 基本覆盖了小型超市的核心统计需求也是评委最常考的三个方向。日销售额报表重点是 GROUP BY 和日期函数SELECT DATE(sale_time) AS day, COUNT(DISTINCT id) AS order_count, SUM(total_amount) AS amount FROM sale_order WHERE status 1 GROUP BY DATE(sale_time) ORDER BY day DESC;COUNT(DISTINCT id) 统计订单数而不是商品数SUM(total_amount) 是当天的营业额WHERE status 1 过滤掉已经作废的单防止把退货款算进收入里。只要模拟数据里有 30 天的订单记录这张表跑出来就是一条漂亮的趋势线。销量 TOP10重点是 JOIN、GROUP BY 和 LIMITSELECT p.name, SUM(si.quantity) AS sale_qty, SUM(si.subtotal) AS sale_amount FROM sale_item si JOIN product p ON p.id si.product_id GROUP BY p.id, p.name ORDER BY sale_qty DESC LIMIT 10;这里为什么要 JOIN product 而不是直接在 sale_item 里放商品名因为商品名可能随时修改如果明细表冗余一份名称历史报表和当前商品名就对不上了。这正好能回答老师关于“规范化”的追问。滞销商品重点是 NOT EXISTS 和日期范围SELECT p.id, p.name, s.quantity FROM product p JOIN stock s ON s.product_id p.id WHERE s.quantity 0 AND NOT EXISTS ( SELECT 1 FROM sale_item si JOIN sale_order so ON si.order_id so.id WHERE si.product_id p.id AND so.sale_time DATE_SUB(CURDATE(), INTERVAL 30 DAY) );它的含义是当前有库存但在最近 30 天没有任何销售记录。NOT EXISTS 比 LEFT JOIN ... IS NULL 更容易向评委解释也少踩“重复数据”的坑。如果时间和篇幅允许你还可以用窗口函数 ROW_NUMBER 排名一次这个进阶写法可以放在最后但别写在主代码里因为会卡住低版本数据库。5.3 角色权限和操作日志让老师相信这不是玩具系统很多课设做完只有一个用户表登录后谁都能删数据。真正要加分至少要区分“管理员”和“收银员”两个角色。实现不需要引入权限框架一张表加一个字段就够sys_user 里已经有 role 字段每次登录后把 role 放进会话菜单渲染时判断一下管理员可以看成本价、进销存报表、供应商维护收银员只能收银、查商品价格、看自己的交班记录。再进一步加一张操作日志表把关键动作记录下来CREATE TABLE operation_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL COMMENT 操作用户ID, action VARCHAR(64) NOT NULL COMMENT 动作如 login/sale/delete, detail VARCHAR(255) NOT NULL DEFAULT COMMENT 动作描述, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT操作日志表;在收银结账成功、商品修改、用户登录这几个关键节点插入日志成本极低但能向老师证明你考虑了审计和回溯。另一个加分的点在密码存储sys_user 的 password 字段类型虽然是 VARCHAR(128)但千万别存明文。哪怕只是用 SHA-256 加固定盐做个哈希也比明文强得多。课设阶段不需要你做完整的认证体系但“我知道不能存明文”这个信息已经足够让你在答辩时多一分底气。6. 交付技巧用 docker-compose 让评委 3 分钟拉起 MySQL 环境前面挖了这么多坑有一个统一的解决思路把数据库环境也做成交付物的一部分。手动装 MySQL、改字符集、建账号至少半小时而用 Docker 只需要一条命令。在 zip 里放一个 docker-compose.yml内容很少version: 3.8 services: mysql: image: mysql:8.0 container_name: supermarket-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: supermarket TZ: Asia/Shanghai ports: - 3306:3306 volumes: - ./sql:/docker-entrypoint-initdb.dDocker 第一次启动 MySQL 容器时会按文件名字母顺序执行挂载目录里的 .sql 脚本。也就是说你只要把建库、建表、初始化数据分别命名为 01_schema.sql、02_init_data.sql放到 sql 目录里评审机器上执行 docker compose up -d等十几秒数据库就是带数据的完整状态。这比让老师在 Windows 上手工导入脚本可靠得多。如果评审环境没有 Docker那就退一步在 README 里写清楚 MySQL 版本、账号密码、导入命令同时提供一个 init.bat 或 init.sh封装一行 mysql 命令行导入。无论走哪条路原则都一样不要指望别人手动建库、手动录数据你能自动化的部分全部自动化。我早几年交课设的习惯是把源码、SQL 脚本和文档拖成一个 zip 就发结果老师机房的 MySQL 版本不对外键导不进去项目直接跑不起来。后来我把这些环境依赖写进 docker-compose连同初始化数据一起打包再没有因为环境问题被叫去现场处理。你现在要做的不是把所有功能堆到最后一晚而是把这个 zip 当成一个交付产品解压、读 README、执行一条命令、看见登录页。这比任何“系统亮点说明”都更有说服力。希望帮到你。本文还有配套的精品资源点击获取