简介这是一份数据库课程设计完整参考项目——某商店进销存管理系统面向计算机类专业学生、数据库初学者及需要完成课设的开发者用于解决数据库原理课程设计中从系统分析到数据库实现的全流程问题。资源为RAR压缩包共3个文件包括SQL脚本、数据库备份BAK和课程设计报告DOCSQL脚本包含建库建表、约束定义和数据处理语句可直接在数据库中执行BAK备份文件可快速还原出完整的进销存业务数据DOC报告则系统阐述了需求分析、系统功能结构、数据模型设计与优化、安全性和完整性要求。整个包体仅704KB轻量便携便于随时查阅和对照练习。内容还覆盖了数据库设计的完整方法论从社会调查选型到SQL编程调试并附有高分课设的排版与文档组织思路可帮助读者加深对数据库系统原理和程序设计语言的理解提升实际动手能力与问题分析能力。目前已有6413人学习下载学习人数持续增加是数据库实践环节值得参考的学习资料。1. 为什么一张空表的设计失误能让整个进销存系统崩掉数据库课程设计里进销存管理系统是最常被选的题目之一。很多同学拿到需求后第一反应是去写界面登录页、商品列表、下单按钮页面做了一堆最后发现库存算不对、订单明细对不上、答辩时老师一句“你这个库存是存在哪里的”就卡住了。这个题目的真正难点从来不是界面而是数据模型——一张外键缺失的空表、一个库存设计欠考虑的字段能在订单量上来之后让你的系统全线翻车。本文把“某商店进销存管理系统”从 ER 模型到建表 SQL、存储过程、触发器、界面对接和验收答辩完整拆开适合正在选题的学生、要带课程设计的老师以及想快速跑通一套可演示的从业者。你将得到的是一条能直接照做的落地路径以及我在实际项目中踩过的坑。2. 先把数据模型立住从 ER 图到建表 SQL2.1 进销存的本质是两张单据不是一堆按钮进销存系统的业务一眼看过去很杂进货、退货、销售、退换、入库、出库、盘点、调拨。但把业务剥开进销存的核心只有两张单进货单和销售单。商品、供应商、客户都是基础资料库存是从这两张单子的明细行“算出来”或“更新出来”的结果。这个认知决定了你的表结构是“单头 单明细”的模式而不是每个模块各建一张宽表。为什么非要拆成单头和明细如果只在进货记录表里塞一行“进了某个商品 50 个”那么一单进多个商品时要么冗余存储要么无法关联。拆成两层后主表存整单的公共信息单号、日期、往来单位、经办人、总金额明细表存每行商品和数量。这个设计一方面符合业务流转更重要的是在查询时你只需要按单号把两层 join 起来统计、对账都能落在明细层。课程设计评分时老师重点看的就是你有没有这种“单据模型”的自觉。2.2 八张核心表与字段取舍我一般把一套最小可用进销存拆成八张表goods商品表、supplier供应商表、customer客户表、goods_category商品分类表可以合并进商品表看老师是否要求三范式、purchase_order进货单主表、purchase_item进货明细表、sale_order销售单主表、sale_item销售明细表。加上一张可选的order_seq用于生成单号序列、一张user表用于登录权限总计十张左右。字段取舍上有几个点很关键。第一价格字段必须用DECIMAL(10,2)不要用FLOAT/DOUBLE。浮点的二进制表示会造成金额尾数误差这在财务对账中是无法容忍的。第二库存字段放在商品表里作为冗余计数同时在触发器里维护它这是课程设计里最直观也最好解释的做法更严格的系统会把库存拆成独立的库存表和流水表但对一到两周的课程设计来说属于过度设计。第三每张基础资料表都加一个is_deleted或status字段用于软删除而不是物理 DELETE后面章节会解释为什么。表名用途关键字段goods商品基础资料goods_id, goods_name, spec, unit, purchase_price, sale_price, stock_qty, min_stocksupplier供应商supplier_id, supplier_name, contact, phonecustomer客户customer_id, customer_name, contact, phonepurchase_order进货单主表order_id, order_no, supplier_id, order_date, total_amount, statuspurchase_item进货明细item_id, order_id, goods_id, quantity, unit_price, amountsale_order销售单主表order_id, order_no, customer_id, order_date, total_amount, statussale_item销售明细item_id, order_id, goods_id, quantity, unit_price, amountorder_seq单号序列seq_date, seq_prefix, seq_value2.3 建库建表 SQL能直接跑的最小版本下面这套 SQL 是完整可跑的 MySQL 建库脚本。我用的是 InnoDB 引擎字符集统一utf8mb4主键自增明细表全部挂物理外键。注意有部分教材不建议用物理外键理由是这样应用层删数据时会被约束挡住。我的看法是课程设计必须用物理外键因为对新手来说物理外键约束本身就能拦住很多不合法数据等真遇到线上高并发再讨论该不该保留。-- 建库统一字符集避免中文乱码 CREATE DATABASE IF NOT EXISTS shop_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE shop_db; -- 商品表库存冗余在商品表由触发器维护 CREATE TABLE goods ( goods_id INT AUTO_INCREMENT PRIMARY KEY, goods_name VARCHAR(64) NOT NULL, spec VARCHAR(32) DEFAULT NULL COMMENT 规格型号, unit VARCHAR(8) NOT NULL DEFAULT 件 COMMENT 计量单位, purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 进货价, sale_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 零售价, stock_qty INT NOT NULL DEFAULT 0 COMMENT 当前库存数, min_stock INT NOT NULL DEFAULT 0 COMMENT 库存预警下限, is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT 1已删除, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_goods_name (goods_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;商品表的stock_qty是故意冗余的。它由后面章节的触发器在每次销售或进货明细插入时自动增减这样查询商品列表时不需要子查询去汇总销售明细表避免在数据量大时统计拖慢页面。min_stock字段用于做库存预警这是一个很容易在答辩时被问到的亮点先留好。-- 供应商 / 客户结构类似Customer 不再重复列出 CREATE TABLE supplier ( supplier_id INT AUTO_INCREMENT PRIMARY KEY, supplier_name VARCHAR(64) NOT NULL, contact VARCHAR(32) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE customer ( customer_id INT AUTO_INCREMENT PRIMARY KEY, customer_name VARCHAR(64) NOT NULL, contact VARCHAR(32) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 进货单主表 CREATE TABLE purchase_order ( order_id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT 单号由存储过程生成, supplier_id INT NOT NULL, order_date DATE NOT NULL, total_amount DECIMAL(12,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0进行中 1已入库, operator VARCHAR(32) DEFAULT NULL COMMENT 经手人, UNIQUE KEY uk_order_no (order_no), CONSTRAINT fk_po_supplier FOREIGN KEY (supplier_id) REFERENCES supplier (supplier_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 进货明细表 CREATE TABLE purchase_item ( item_id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, goods_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(10,2) NOT NULL DEFAULT 0, amount DECIMAL(12,2) NOT NULL DEFAULT 0, CONSTRAINT fk_pi_order FOREIGN KEY (order_id) REFERENCES purchase_order (order_id), CONSTRAINT fk_pi_goods FOREIGN KEY (goods_id) REFERENCES goods (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 销售单主表与销售明细表结构同上外键换成 customer CREATE TABLE sale_order ( order_id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, customer_id INT NOT NULL, order_date DATE NOT NULL, total_amount DECIMAL(12,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0, operator VARCHAR(32) DEFAULT NULL, UNIQUE KEY uk_so_no (order_no), CONSTRAINT fk_so_customer FOREIGN KEY (customer_id) REFERENCES customer (customer_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE sale_item ( item_id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, goods_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(10,2) NOT NULL DEFAULT 0, amount DECIMAL(12,2) NOT NULL DEFAULT 0, CONSTRAINT fk_si_order FOREIGN KEY (order_id) REFERENCES sale_order (order_id), CONSTRAINT fk_si_goods FOREIGN KEY (goods_id) REFERENCES goods (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表脚本的逻辑说明外键约束保证了“明细必须属于一张存在的单据”这是单据模型的基本盘order_no建唯一索引是防单号重复的第一道防线所有金额和数量字段都定义了默认值避免程序插入时漏字段导致 MySQL 在严格模式下报错。参数上如果你用的是 SQL Server表结构可以原样保留只需要把所有反引号去掉并把TINYINT换成BIT或INT即可主键自增写法从AUTO_INCREMENT换成IDENTITY(1,1)。3. 让库存自己动起来存储过程与触发器怎么写才不翻车3.1 为什么课程设计里增删改查不是全部很多课程设计项目提交后老师只问一个问题库存是怎么变动的如果答案是“在界面里先查出来改一下再写回”那就把数据库设计题做成了普通 JavaWeb 作业。进销存的价值恰恰在于库存、金额、单号这些数据必须在数据库层面保持一致性而不是靠应用层的人去记。这里就能拉出增删改查以外的两个东西存储过程和触发器。存储过程解决的是“一个完整操作涉及多张表”的问题比如生成单号 插入主表 更新总金额你把它封装成一个数据库里的函数程序端只需要一行CALL调用事务边界也在数据库里。触发器解决的是“某个动作发生后另一张表必须跟着变”的问题比如销售明细插入后库存必须扣减写成触发器后不管从哪个界面、哪个模块做销售扣库存这个动作都不会漏。3.2 单号生成与销售入库两个存储过程单号的常见格式是前缀 日期 流水号比如XS202504190001。新手最容易写的方案是先查现有最大单号再加一这在单用户演示时没问题一旦多点同时开单就有重复风险。更稳的做法是用一张独立序列表配合 MySQL 的INSERT ... ON DUPLICATE KEY UPDATE让数据库来做原子递增。-- 序列表按日期 前缀记录当天的流水 CREATE TABLE order_seq ( seq_date DATE NOT NULL, seq_prefix VARCHAR(4) NOT NULL, seq_value INT NOT NULL DEFAULT 1, PRIMARY KEY (seq_date, seq_prefix) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 生成单号存储过程 DELIMITER $$ CREATE PROCEDURE sp_gen_order_no( IN p_prefix VARCHAR(4), -- 单号前缀如 XS 销售, JH 进货 OUT p_order_no VARCHAR(32) -- 返回生成的完整单号 ) BEGIN DECLARE v_date_str VARCHAR(10); DECLARE v_seq INT; SET v_date_str DATE_FORMAT(CURDATE(), %Y%m%d); -- 当天第一次插入则 seq_value1后续插入则 1 INSERT INTO order_seq(seq_date, seq_prefix, seq_value) VALUES (CURDATE(), p_prefix, 1) ON DUPLICATE KEY UPDATE seq_value seq_value 1; -- 取出递增后的值 SELECT seq_value INTO v_seq FROM order_seq WHERE seq_date CURDATE() AND seq_prefix p_prefix; -- 拼接前缀 年月日 4位流水如 XS202504190001 SET p_order_no CONCAT(p_prefix, v_date_str, LPAD(v_seq, 4, 0)); END$$ DELIMITER ;逻辑说明这个存储过程的核心是ON DUPLICATE KEY UPDATE它在有并发时也能安全地让序列加一因为 MySQL 会对主键行加锁。LPAD(v_seq, 4, 0)把数字 1 补成0001保证单号位数对齐。调用方式在程序里是CALL sp_gen_order_no(XS, no); SELECT no;。参数里的IN p_prefix决定业务前缀你完全可以扩展成退货用TH、盘点用PD一套过程通吃。要注意如果课程设计用的是 SQL Server等价写法是MERGE否则没有ON DUPLICATE KEY UPDATE可用。销售入库的过程一般不建议全部塞在存储过程里因为商品明细是变长的传参很别扭。我的习惯是存储过程生成单号并插入主表明细由程序在事务中循环插入扣库存由触发器自动完成。这样代码读起来清楚事务失败时也能整体回滚。-- 创建销售单主表记录的存储过程 DELIMITER $$ CREATE PROCEDURE sp_create_sale_order( IN p_customer_id INT, IN p_operator VARCHAR(32), IN p_total DECIMAL(12,2), OUT p_order_id INT, OUT p_order_no VARCHAR(32) ) BEGIN DECLARE v_no VARCHAR(32); -- 先取单号再插入主表 CALL sp_gen_order_no(XS, v_no); INSERT INTO sale_order(order_no, customer_id, order_date, total_amount, status, operator) VALUES (v_no, p_customer_id, CURDATE(), p_total, 0, p_operator); SET p_order_id LAST_INSERT_ID(); SET p_order_no v_no; END$$ DELIMITER ;调用后程序拿到p_order_id再往sale_item里一条条插明细。参数说明p_total是前端算好的整单金额但这里要注意——服务端必须用明细金额重新累加一遍否则用户篡改页面上的合计金额就能以低价入库。更严谨的做法是存储过程里根据明细临时表SUM我在后面的第 6 章再讲验证方法。3.3 触发器库存扣减放在明细层还是单据层扣库存有两个时间点插入明细时逐行扣或者整单完成时一次扣。逐行扣看着啰嗦但它有个好处哪一行库存不足报错时能精确定位到商品。整单扣则必须等单据审核业务复杂度更高。课程设计里我推荐逐行扣也就是在sale_item的AFTER INSERT上做更新。-- 销售明细插入后扣减商品库存库存不足直接抛错 DELIMITER $$ CREATE TRIGGER trg_sale_deduct AFTER INSERT ON sale_item FOR EACH ROW BEGIN -- 条件更新只有库存够扣时才更新行数 UPDATE goods SET stock_qty stock_qty - NEW.quantity WHERE goods_id NEW.goods_id AND stock_qty NEW.quantity; -- 上面 UPDATE 影响 0 行说明库存不足抛异常让事务回滚 IF ROW_COUNT() 0 THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 库存不足不能销售; END IF; END$$ -- 进货明细插入后自动增加库存 CREATE TRIGGER trg_purchase_add AFTER INSERT ON purchase_item FOR EACH ROW BEGIN UPDATE goods SET stock_qty stock_qty NEW.quantity WHERE goods_id NEW.goods_id; END$$ DELIMITER ;这段触发器的关键点全在WHERE条件里。把stock_qty NEW.quantity写进UPDATE而不是“先 SELECT 判断再 UPDATE”是因为后者在并发时会读出过期的库存数导致超卖。条件更新失败返回 0 行然后用SIGNAL抛异常事务里前面的任何插入都会被回滚。注意 MySQL 的SIGNAL语法在 5.7 以上才支持MariaDB 10.2.7 以上支持选型时确认版本。注意触发器里的ROW_COUNT()注意作用域它读的是刚执行完那条UPDATE影响的行数。如果你在触发器里先执行了别的写操作要把ROW_COUNT()紧跟在UPDATE之后否则拿到的是上一条语句的结果。4. 从数据库到界面用最小前端把库串起来4.1 数据访问层怎么选驱动选择与连接参数数据库课程设计的界面层用什么语言并不重要重要的是数据访问参数别配错。选型上我现在通常给学生的建议是如果是两到三周的作品用 Python PyMySQL 写控制台或 Flask 界面最快如果开题要求 Java就用 JDBC Swing 或 JSP逻辑完全一样只是驱动的类名和连接串格式不同。下面以 Python 为例因为代码最短、最容易读。import pymysql conn pymysql.connect( host127.0.0.1, port3306, usershop_app, passwordyour_password, databaseshop_db, charsetutf8mb4, # 连接层字符集必须和库表一致 autocommitFalse, # 关闭自动提交事务由程序显式控制 cursorclasspymysql.cursors.DictCursor, ) cursor conn.cursor() try: # 生成单号调用存储过程 cursor.execute(CALL sp_create_sale_order(%s, %s, %s, oid, ono), (1, admin, 299.00)) cursor.execute(SELECT oid AS order_id, ono AS order_no) row cursor.fetchone() order_id row[order_id] # 插入销售明细库存扣减由触发器完成 cursor.execute( INSERT INTO sale_item(order_id, goods_id, quantity, unit_price, amount) VALUES(%s, %s, %s, %s, %s), (order_id, 101, 2, 19.90, 39.80), ) conn.commit() # 手动提交事务结束 except Exception as exc: conn.rollback() # 任意一步失败主表、明细、库存全部回滚 print(操作失败已回滚, exc) raise finally: cursor.close() conn.close()几个连接参数的说明要格外留意。autocommitFalse必须设这是整个事务一致性的根基如果保持自动提交明细插入成功而后续代码报错库存已经扣了但主单还在半完成状态。charsetutf8mb4要与建库时的字符集一致否则会出现中文乱码。DictCursor只是为了取数时能用列名而不是下标个人习惯不影响功能。如果换成 JavaJDBC 连接串写成jdbc:mysql://localhost:3306/shop_db?useUnicodetruecharacterEncodingutf8mb4事务用conn.setAutoCommit(false)和conn.commit()思路完全一致。4.2 增删改查的边界哪些操作放界面哪些必须走数据库界面层能做到的是商品管理、供应商和客户维护、单据统计查询这些“读多写少”的操作。里面对应的 SQL 无非就是SELECT、UPDATE、DELETE也就是课程设计里总被提到的数据库增删改查基本功。但有两类操作绝对不能直接写在界面代码里一是库存变动二是单号生成。库存变动必须走触发器单号必须走存储过程否则逻辑可以被绕过。我一般要求学生把界面层只保留三个职责收集用户输入、调用存储过程或参数化 SQL、展示结果。商品新增就是个标准的参数化插入cursor.execute( INSERT INTO goods(goods_name, spec, unit, purchase_price, sale_price, min_stock) VALUES(%s, %s, %s, %s, %s, %s), (可口可乐 500ml, 24瓶/箱, 箱, 45.00, 72.00, 5), )注意参数绝不能拼字符串。写成fINSERT INTO goods VALUES({name}, ...)这种形式一旦商品名里出现单引号SQL 就被注入漏洞击中。课程设计答辩时老师会翻代码查这一点用占位符%s是底线。4.3 用户的角色与权限角色表 最小权限系统里通常有两种角色管理员能做所有增删改查营业员只能做销售、查库存不能改基础资料价。在数据库层面用 MySQL 的用户权限直接控比在 Java 代码里判断if (role admin)更贴近“数据库设计”这门课的评分点。-- 创建应用账号并授权 CREATE USER shop_adminlocalhost IDENTIFIED BY strong_pass_1; CREATE USER shop_saleslocalhost IDENTIFIED BY sales_pass_1; -- 管理员所有权限 GRANT SELECT, INSERT, UPDATE, DELETE ON shop_db.* TO shop_adminlocalhost; -- 营业员可查询任意表可写销售相关的表和商品表的部分字段 GRANT SELECT ON shop_db.* TO shop_saleslocalhost; GRANT INSERT, UPDATE ON shop_db.sale_order TO shop_saleslocalhost; GRANT INSERT, UPDATE ON shop_db.sale_item TO shop_saleslocalhost; GRANT UPDATE (stock_qty) ON shop_db.goods TO shop_saleslocalhost; FLUSH PRIVILEGES;这段把“最小权限”落到了数据库层营业员能改goods.stock_qty却改不了sale_price因为列级授权只给了库存字段。FLUSH PRIVILEGES用于让授权立即生效。日常系统里应用层也还需要一张user表存登录账号和角色和 MySQL 用户表分开前者管业务登录后者管连接数据库的权限边界两层别混。提示不要给应用直接连root。课程设计里最常见的扣分项就是代码里写死userroot, password123456答辩老师一看就会问“业务账号和数据库管理账号为什么不分”。单独建两个受限账号这是数据库安全设计里最便宜的改进。5. 数据库课程设计避坑5 个让系统翻车的常见问题5.1 库存负数并发场景下超卖现象界面显示库存还剩 3 件两个营业员同时各下单 2 件结果库存变成 -1而两张销售单都成功了。原因这类系统的常见错误写法是应用层先 SELECT 出库存判断够数后 UPDATE。两个请求同时读到 3同时判断“够”同时扣减成 1最终并没有负数但如果程序逻辑是先扣后查或者读到了过期值负数就会出现。本质是“检查与更新不原子”。解决用我第 3.3 节触发器里的写法把条件放进 UPDATE 本身UPDATE goods SET stock_qty stock_qty - 2 WHERE goods_id 1 AND stock_qty 2。数据库的行锁保证同一时间只有一个事务能成功ROW_COUNT() 0就抛错回滚。按这个方案第二笔会因为更新 0 行而失败库存保持 1不会变负。5.2 改表后程序报字段不存在可视化工具改表的坑现象程序原本跑得好好的某天用可视化工具给商品表加了一个字段后又删了接下来应用层执行 INSERT 时报Unknown column spec in field list。原因可视化工具里改表时容易误勾选、误删列或者把字段名改了大小写而程序里的 SQL 还是旧结构。更隐蔽的是 MySQL 的字段名不区分大小写但列顺序可能变了用INSERT INTO goods VALUES(...)这种不给列名的写法会插错位置。解决数据库结构变更一律通过 SQL 脚本文件管理而不是直接改线上库。建一个schema.sql和upgrade.sql每次改动都记录一行ALTER TABLE程序代码里所有 INSERT 都必须显式列出字段名。这样改了结构后对照脚本就能定位差异。另一个附加好处是答辩时能展示版本化建表脚本这是从业者习惯的加分项。5.3 外键约束挡住删除操作现象在商品管理里物理删除某个商品数据库报Cannot delete or update a parent row因为进货明细和销售明细都引用着它。原因这是外键约束的正常保护动作。明细表里留有历史单据直接删商品会破坏单据的可追溯性业务上不允许不留下任何痕迹地抹掉一个商品。解决用软删除代替物理删除。goods表已预留is_deleted字段删除时执行UPDATE goods SET is_deleted 1 WHERE goods_id ?所有查询商品的 SQL 都强制带AND is_deleted 0。这样历史明细 join 商品表时依然能显示名称只是不再出现在可选商品列表里。关于“删掉的数据怎么处理”这个答辩问题软删除是标准答案。5.4 中文乱码库、表、连接三级不一致现象插入的中文商品名显示成???或者用可视化工具看是正常程序里读出来全是乱码。原因MySQL 的字符集有服务器级、数据库级、表级、连接级四层。常见情况是建库时没指定字符集继承了默认的latin1而连接串里写了utf8mb4写入时被转换、读出时回来两层一错就乱。解决三层全部统一到utf8mb4。建库时写DEFAULT CHARACTER SET utf8mb4表和连接都指定utf8mb4如果已经建了 latin1 的库执行ALTER DATABASE shop_db CHARACTER SET utf8mb4后还要逐表转换。改动后插入的新数据会正常旧数据已经损坏的记录需要重建。校验一条命令SHOW VARIABLES LIKE character_set_%看character_set_database和character_set_connection是否都是utf8mb4。5.5 单号重复手动 MAX1 的并发缺陷现象同一天出现两个XS202504190007且两单对应的商品还不一样。原因单号生成逻辑写在了程序里——SELECT MAX(order_no) FROM sale_order WHERE order_no LIKE XS%取出来加一。两个请求同时查 MAX 得到同一个值就生成了同样的单号。解决改用第 3.2 节的order_seq表和sp_gen_order_no存储过程。即使用INSERT ... ON DUPLICATE KEY UPDATE同一天同一前缀只会有一个序列值入账数据库在这个主键行上加锁天然串行。再加上sale_order.order_no字段上的唯一约束UNIQUE KEY uk_so_no万一逻辑有漏第二次插入也会被唯一索引拦下。这属于双保险前者保并发后者做兜底。6. 验收答辩前要做的验证一套能自证的数据检查清单交题之前按下面这个清单把系统亲手跑一遍每个检查项都留下截图和 SQL 执行结果答辩时直接摆数据比讲概念有说服力得多。整张表就是你的“自证材料”检查项操作预期结果库存扣减创建销售单插入 2 件商品goods.stock_qty 对应减少 2库存不足拦截下单 100 件库存只剩 5 件触发器抛错整单回滚明细无残留进货加库存创建进货单插入 10 件goods.stock_qty 增加 10单号唯一连续创建 3 张销售单3 个不同单号流水递增外键完整性删除已被引用的商品删除被拒数据仍完整事务回滚明细插入中途抛异常主表订单也不存在库存不变权限隔离用营业员账号改商品售价操作被拒列级授权生效验证时还有一个我习惯做的动作在sale_item里故意插入一条数量为 0 的明细看触发器是否允许。按正常规则应该要在quantity上加CHECK (quantity 0)MySQL 8.0.16 以上会真正执行 CHECK 约束更老版本只解析不生效所以触发器或应用层校验至少要留一道。答辩时被问“系统哪里能体现数据库设计能力”我的回答通常指向二处单号与库存的一致性由存储过程和触发器保证而不是靠界面拼凑可追溯性由物理外键 软删除保证历史单据不会被抹除。再补一句“数据访问层全部参数化账号区分管理员和营业员”基本能覆盖大多数提问角度。最后说点个人习惯我每次拿到这类课程设计都会先花一个小时把表结构写进一个ER 图哪怕最后不交图这个习惯也能省下后面调字段的半天。这次按八张表把库存拆干净后后面的界面层其实只是查表和调过程工作量集中在数据层是对的。希望帮到你。本文还有配套的精品资源点击获取