ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据库课设小型超市管理系统:从表设计到事务与交付避坑

数据库课设小型超市管理系统:从表设计到事务与交付避坑

简介:一套面向高校数据库课程设计、毕业设计及期末大作业的小型超市管理系统完整工程,采用前端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 '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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( creator=pymysql, # 使用的数据库驱动 maxconnections=20, # 连接池最大连接数 mincached=2, # 启动时预先创建的空闲连接数 maxcached=5, # 最多保持的空闲连接数 blocking=True, # 无空闲连接时阻塞等待,而不是直接报错 ping=1, # 每次取连接前执行一次探活 host="127.0.0.1", port=3306, user="sale_user", password="123456", database="supermarket", charset="utf8mb4" )

参数说明先说三个:maxconnections 是连接池能创建的上限,课设项目给 10 到 20 都够;mincached 是启动时预建两条,避免第一次点击按钮时卡顿;ping=1 表示从池里拿出来的连接先验证一下是否还活着,因为 MySQL 的 wait_timeout 可能把长时间空闲连接断开,不探活的话,你点第一次查询就会报 connection is closed。

如果你用 Java,思路一样,只是框架替你做了一大半。以 Spring Boot 为例,HikariCP 默认就在依赖里,yml 里改几个参数即可:

spring: datasource: url: jdbc:mysql://localhost:3306/supermarket?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8 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(cursor=pymysql.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 里加 allowPublicKeyRetrieval=true 和 useSSL=false;如果你是手动建的数据库用户,也可以把它改回老插件:

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 参数里写 charset="utf8mb4";SQL 文件用编辑器另存为 UTF-8 无 BOM。如果脚本已经在库里跑了,乱码已经进去,补救办法是在导入前执行一句 SET NAMES utf8mb4,再重新跑一遍数据。

坑三是时区导致的时间字段错乱。现象是 JDBC 启动时直接报错,最后一行写着 The server time zone value is unrecognized。原因是 MySQL 的时区信息不完整,或者驱动不知道当前会话该用哪个时区。解决是在 JDBC URL 后面加 serverTimezone=Asia/Shanghai。Python 的 pymysql 一般不强制要求时区,但连接参数里最好也写上 init_command='SET 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 和 LIMIT:

SELECT 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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.d"

Docker 第一次启动 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、执行一条命令、看见登录页。这比任何“系统亮点说明”都更有说服力。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进