简介:这份数据库课程设计资源围绕某送水公司的送水业务展开,面向高校计算机相关专业学生及需要完成数据库课设的学习者,帮助解决从需求分析到数据库落地的完整设计问题。资源包共3个文件,包含1个doc设计报告、1个sql建库脚本和1个bak数据库备份,压缩包约388KB,报告内附清晰的设计思路、流程图与E-R图,建库代码也一并收录其中。系统功能覆盖工作人员与客户信息管理、矿泉水类别与供应商管理、入库出库管理,并通过触发器实现出入库时对应类型矿泉水数量的自动增减,借助存储过程统计每位送水员工指定月份的送水数量,以及查询指定月份用水量最大的前10名用户并按用水量递减排列,同时建立表间参照完整性约束。目前已有1543人学习下载,适合作为数据库课设的参考方案与排错思路来源。
1. 送水系统数据库课设:从需求到建表的完整落地路径
送水公司的日常调度远比想象中琐碎:客户打电话要两桶水,配送员电瓶车只能装八桶,仓库里还有三个品牌五种规格,月底还要按阶梯价结算。把这些塞进一个数据库课设里,核心不是写多花哨的界面,而是让订单、库存、配送、结算四条线在数据层面自洽。这个题目属于数据库课程设计里典型的「业务闭环型」选题,适合已经学完 SQL 基础、想用一个真实场景把建表、约束、事务、索引串起来的人。它不需要分布式,不需要高并发,但要求你把「一桶水从下单到签收」的每一步都映射成可查询、可回滚的数据状态。下面按我实际带学生做课设的顺序,从需求拆解一路讲到能跑起来的建表脚本和查询验证。
2. 需求拆解与实体关系:把送水业务翻译成表结构
2.1 先画业务流,再定实体
送水系统的业务流其实就一条主线:客户下单 → 调度分配配送员 → 配送员取水出库 → 送达签收 → 生成结算记录。围绕这条线,至少需要五类实体:客户、水品(品牌+规格)、订单、配送任务、库存流水。很多同学一上来就建user表,结果把客户和配送员混在一起,后面权限和结算全乱。我的做法是先把角色分开:客户表只存订水方信息,员工表存配送员和仓管,用角色字段区分。
客户表的关键字段不是姓名电话,而是地址和配送区域。送水是强区域业务,同一个配送员只负责几个小区,所以地址要拆成「小区+楼栋+门牌」,区域单独建一张区域表,客户表外键关联区域。这样调度时按区域筛配送员,一条 SQL 就能出候选列表。
水品表要处理品牌和规格的二维组合。常见做法是建一张water_product表,字段包括品牌、规格(桶装 18.9L / 瓶装 550ml 等)、单价、当前库存。注意单价不要写死在订单里,订单明细要冗余一份下单时的单价,否则调价后历史订单金额会变,这是课设答辩最容易被问的点。
订单表分主表和明细表。主表存客户、下单时间、总金额、状态;明细表存每笔订单买了哪个水品、数量、单价。状态字段用枚举值:待支付、待配送、配送中、已签收、已取消。配送任务表关联订单和配送员,记录出发时间、送达时间、签收状态。库存流水表记录每一次入库和出库,出库关联订单明细,这样库存对不上时能追溯到具体哪一单。
2.2 ER 图到建表脚本的映射规则
从 ER 图到物理表,三个规则必须守住:第一,多对多关系必须拆中间表,比如订单和水品之间用订单明细表;第二,所有外键列必须建索引,否则后面按客户查订单会全表扫;第三,金额字段用DECIMAL(10,2),不要用FLOAT,浮点误差在结算时是灾难。
下面是我常用的建表顺序,先建被引用的表,再建引用表,避免外键报错:
-- 区域表:配送区域划分 CREATE TABLE region ( region_id INT PRIMARY KEY AUTO_INCREMENT, region_name VARCHAR(50) NOT NULL COMMENT '区域名称,如XX小区', delivery_fee DECIMAL(5,2) DEFAULT 0.00 COMMENT '该区域配送费' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 客户表:订水方信息 CREATE TABLE customer ( customer_id INT PRIMARY KEY AUTO_INCREMENT, customer_name VARCHAR(30) NOT NULL, phone VARCHAR(15) NOT NULL UNIQUE, address_detail VARCHAR(200) NOT NULL COMMENT '楼栋门牌', region_id INT NOT NULL, balance DECIMAL(10,2) DEFAULT 0.00 COMMENT '账户余额,用于月结', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (region_id) REFERENCES region(region_id), INDEX idx_region (region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 水品表:品牌+规格组合 CREATE TABLE water_product ( product_id INT PRIMARY KEY AUTO_INCREMENT, brand VARCHAR(30) NOT NULL, spec VARCHAR(20) NOT NULL COMMENT '如18.9L、550ml', unit_price DECIMAL(6,2) NOT NULL, stock_qty INT NOT NULL DEFAULT 0, UNIQUE KEY uk_brand_spec (brand, spec) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 员工表:配送员和仓管 CREATE TABLE employee ( emp_id INT PRIMARY KEY AUTO_INCREMENT, emp_name VARCHAR(30) NOT NULL, role ENUM('delivery','warehouse','admin') NOT NULL, phone VARCHAR(15), region_id INT COMMENT '配送员负责区域', FOREIGN KEY (region_id) REFERENCES region(region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单主表 CREATE TABLE orders ( order_id INT PRIMARY KEY AUTO_INCREMENT, customer_id INT NOT NULL, order_time DATETIME DEFAULT CURRENT_TIMESTAMP, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, status ENUM('pending','assigned','delivering','signed','cancelled') DEFAULT 'pending', FOREIGN KEY (customer_id) REFERENCES customer(customer_id), INDEX idx_customer (customer_id), INDEX idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表 CREATE TABLE order_detail ( detail_id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, unit_price DECIMAL(6,2) NOT NULL COMMENT '下单时单价快照', FOREIGN KEY (order_id) REFERENCES orders(order_id), FOREIGN KEY (product_id) REFERENCES water_product(product_id), INDEX idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 配送任务表 CREATE TABLE delivery_task ( task_id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL UNIQUE, emp_id INT NOT NULL, depart_time DATETIME, arrive_time DATETIME, sign_status TINYINT DEFAULT 0 COMMENT '0未签收 1已签收', FOREIGN KEY (order_id) REFERENCES orders(order_id), FOREIGN KEY (emp_id) REFERENCES employee(emp_id), INDEX idx_emp (emp_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存流水表 CREATE TABLE stock_log ( log_id INT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL, change_qty INT NOT NULL COMMENT '正数入库,负数出库', log_type ENUM('in','out','adjust') NOT NULL, ref_order_id INT COMMENT '关联订单,调整时为空', log_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (product_id) REFERENCES water_product(product_id), INDEX idx_product (product_id), INDEX idx_time (log_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段脚本里几个参数值得展开说。utf8mb4是必须的,客户姓名可能有生僻字,utf8三字节存不下。DECIMAL(10,2)表示总共 10 位、小数 2 位,最大 99999999.99,足够送水业务用。订单明细里的unit_price是快照字段,和water_product.unit_price分开,这是为了历史订单金额稳定。delivery_task.order_id加了UNIQUE,因为一个订单只对应一个配送任务,防止重复派单。库存流水的change_qty用正负号区分出入库,比单独建两张表更简洁,查询时SUM(change_qty)就是当前库存。
提示:建表时如果 MySQL 报「Cannot add foreign key constraint」,先检查被引用表的引擎是不是 InnoDB,MyISAM 不支持外键。另外字符集和排序规则要一致,否则外键也会失败。
2.3 用 SQL 验证表结构是否撑得住业务
建完表别急着写界面,先用几条查询验证结构。比如「查某客户所有未签收订单」:
SELECT o.order_id, o.order_time, o.status, GROUP_CONCAT(CONCAT(w.brand,' ',w.spec,' x',od.quantity)) AS items FROM orders o JOIN order_detail od ON o.order_id = od.order_id JOIN water_product w ON od.product_id = w.product_id WHERE o.customer_id = 1 AND o.status IN ('pending','assigned','delivering') GROUP BY o.order_id;这条 SQL 用GROUP_CONCAT把明细拼成一行,方便调度员一眼看清。如果这条查不出结果或者报错,说明外键或索引有问题。再验证库存:「查当前库存和流水汇总是否一致」:
SELECT w.product_id, w.brand, w.spec, w.stock_qty, COALESCE(SUM(s.change_qty),0) AS log_sum FROM water_product w LEFT JOIN stock_log s ON w.product_id = s.product_id GROUP BY w.product_id HAVING w.stock_qty <> COALESCE(SUM(s.change_qty),0);这条查出来如果有行,说明库存表和流水对不上,要么是初始化库存没写流水,要么是出库时漏记。课设答辩时老师最爱问「你怎么保证库存准确」,这条 SQL 就是答案。
3. 增删改查与事务:下单、出库、签收的原子操作
3.1 下单接口的完整事务写法
送水系统最核心的操作是下单:插入订单主表、插入明细、扣减库存、写库存流水。这四步必须在一个事务里,否则扣了库存没生成订单,或者订单生成了库存没扣,都是血泪教训。下面是我常用的存储过程写法:
DELIMITER // CREATE PROCEDURE place_order( IN p_customer_id INT, IN p_product_id INT, IN p_quantity INT ) BEGIN DECLARE v_price DECIMAL(6,2); DECLARE v_stock INT; DECLARE v_order_id INT; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '下单失败,已回滚'; END; START TRANSACTION; -- 锁定库存行,防止并发超卖 SELECT unit_price, stock_qty INTO v_price, v_stock FROM water_product WHERE product_id = p_product_id FOR UPDATE; IF v_stock < p_quantity THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足'; END IF; INSERT INTO orders (customer_id, total_amount, status) VALUES (p_customer_id, v_price * p_quantity, 'pending'); SET v_order_id = LAST_INSERT_ID(); INSERT INTO order_detail (order_id, product_id, quantity, unit_price) VALUES (v_order_id, p_product_id, p_quantity, v_price); UPDATE water_product SET stock_qty = stock_qty - p_quantity WHERE product_id = p_product_id; INSERT INTO stock_log (product_id, change_qty, log_type, ref_order_id) VALUES (p_product_id, -p_quantity, 'out', v_order_id); COMMIT; END // DELIMITER ;关键点在SELECT ... FOR UPDATE,它给库存行加了排他锁,同一时刻另一个下单请求必须等锁释放,避免两个订单同时读到相同库存然后都扣减导致超卖。EXIT HANDLER捕获任何 SQL 异常后回滚,保证要么全成功要么全失败。LAST_INSERT_ID()拿到刚插入的订单号,用于明细和流水关联。
调用方式:
CALL place_order(1, 2, 3);参数依次是客户 ID、水品 ID、数量。如果库存不足会抛异常,事务回滚,订单和流水都不会留下。
3.2 配送签收与结算的联动更新
配送员送达后,要更新配送任务签收状态、订单状态,并可能触发月结扣款。这三步同样要事务:
DELIMITER // CREATE PROCEDURE sign_order(IN p_task_id INT) BEGIN DECLARE v_order_id INT; DECLARE v_customer_id INT; DECLARE v_amount DECIMAL(10,2); DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '签收失败'; END; START TRANSACTION; SELECT order_id INTO v_order_id FROM delivery_task WHERE task_id = p_task_id AND sign_status = 0 FOR UPDATE; IF v_order_id IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '任务不存在或已签收'; END IF; UPDATE delivery_task SET sign_status = 1, arrive_time = NOW() WHERE task_id = p_task_id; UPDATE orders SET status = 'signed' WHERE order_id = v_order_id; -- 如果是月结客户,扣减余额 SELECT customer_id, total_amount INTO v_customer_id, v_amount FROM orders WHERE order_id = v_order_id; UPDATE customer SET balance = balance - v_amount WHERE customer_id = v_customer_id AND balance >= v_amount; COMMIT; END // DELIMITER ;这里FOR UPDATE锁住配送任务行,防止重复签收。余额扣减加了balance >= v_amount条件,如果余额不够,更新影响行数为 0,但事务不会自动回滚,需要额外判断。更严谨的做法是在UPDATE后检查ROW_COUNT(),如果为 0 就抛异常回滚。这个细节课设里不要求,但答辩时提一句能加分。
3.3 常用查询与索引命中验证
课设里老师常让演示「查某配送员今天送了多少单」「查某水品本月出库总量」。这些查询要能走索引,否则数据量一大就慢。比如:
-- 查配送员今日签收单数 SELECT e.emp_name, COUNT(*) AS signed_count FROM delivery_task dt JOIN employee e ON dt.emp_id = e.emp_id WHERE dt.emp_id = 3 AND dt.sign_status = 1 AND DATE(dt.arrive_time) = CURDATE() GROUP BY e.emp_name;这条 SQL 在delivery_task上有idx_emp索引,但DATE(arrive_time)函数会导致索引失效。优化写法是改成范围查询:
AND dt.arrive_time >= CURDATE() AND dt.arrive_time < CURDATE() + INTERVAL 1 DAY用EXPLAIN看执行计划,type列从ALL变成range就说明索引生效了。这是数据库课设里最实用的优化技巧之一,比背范式定义有用得多。
4. 避坑与排查:课设里最容易翻车的五个点
4.1 外键约束导致插入顺序错误
现象:插入订单明细时报Cannot add or update a child row: a foreign key constraint fails。
原因:订单明细引用了订单主表和水品表,如果先插明细再插主表,或者水品 ID 不存在,就会报这个错。
解决:严格按「被引用表先插」的顺序。初始化数据时先插区域、客户、水品、员工,再插订单和明细。如果已经乱了,用SET FOREIGN_KEY_CHECKS = 0;临时关闭外键检查,插完再打开,但这是补救手段,不要养成习惯。
4.2 库存扣成负数却没报错
现象:查询库存发现stock_qty是负数,但下单时没提示库存不足。
原因:UPDATE water_product SET stock_qty = stock_qty - p_quantity没有加WHERE stock_qty >= p_quantity条件,MySQL 不会自动阻止负数。
解决:在UPDATE语句里加条件AND stock_qty >= p_quantity,然后检查ROW_COUNT()是否为 0。或者在事务开始时就SELECT ... FOR UPDATE锁定并判断,像 3.1 节的存储过程那样。两种方式选一种,不要既锁又加条件,逻辑会乱。
4.3 中文乱码从建库就埋下
现象:插入客户姓名「张伟」后查询显示??或乱码。
原因:建库时用了默认字符集latin1,或者连接字符串没指定utf8mb4。
解决:建库语句写全CREATE DATABASE water_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;,建表也指定utf8mb4。连接时 JDBC 加?useUnicode=true&characterEncoding=utf8。已经建错的用ALTER DATABASE和ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4改,但数据可能已经损坏,最好重建。
4.4 事务没提交导致数据「消失」
现象:在命令行里CALL place_order(...)后查订单表,看不到新订单。
原因:MySQL 默认autocommit=1,但存储过程里START TRANSACTION后如果没执行COMMIT,或者客户端连接断开,事务会回滚。
解决:检查存储过程里是否有COMMIT,以及EXIT HANDLER是否误触发了ROLLBACK。用SHOW ENGINE INNODB STATUS看最近的事务状态。课设演示时建议用SET autocommit=0手动控制,每一步都确认。
4.5 日期函数让索引失效
现象:查「今天订单」的 SQL 越来越慢,EXPLAIN显示type=ALL。
原因:WHERE DATE(order_time) = CURDATE()对列用了函数,索引无法使用。
解决:改成范围查询WHERE order_time >= CURDATE() AND order_time < CURDATE() + INTERVAL 1 DAY。同理,YEAR(order_time)=2024改成order_time >= '2024-01-01' AND order_time < '2025-01-01'。这个坑在课设报告里写一句「索引优化实践」,老师会觉得你确实动手调过。
5. 进阶技巧:用视图和触发器把课设做出工程味
课设如果只到建表和增删改查,分数不会太高。加两个东西立刻不一样:一个是视图,把常用查询封装成虚拟表;一个是触发器,在库存变动时自动写流水,减少应用层遗漏。
先看视图。调度员每天要看「待配送订单及客户地址」,这条查询涉及订单、客户、区域三张表,写起来长,不如建视图:
CREATE VIEW v_pending_delivery AS SELECT o.order_id, c.customer_name, c.phone, CONCAT(r.region_name, c.address_detail) AS full_address, o.total_amount, o.order_time FROM orders o JOIN customer c ON o.customer_id = c.customer_id JOIN region r ON c.region_id = r.region_id WHERE o.status = 'pending' ORDER BY o.order_time ASC;之后SELECT * FROM v_pending_delivery;就能直接出结果。视图的好处是逻辑集中,改地址拼接规则只改视图定义,不用改应用代码。注意视图不存数据,每次查都执行底层 SQL,所以底层表的索引还是要建好。
再看触发器。库存流水如果靠应用层写,万一漏了就对不上。用触发器在water_product更新库存时自动记录:
DELIMITER // CREATE TRIGGER trg_stock_after_update AFTER UPDATE ON water_product FOR EACH ROW BEGIN IF OLD.stock_qty <> NEW.stock_qty THEN INSERT INTO stock_log (product_id, change_qty, log_type, log_time) VALUES (NEW.product_id, NEW.stock_qty - OLD.stock_qty, IF(NEW.stock_qty > OLD.stock_qty, 'in', 'out'), NOW()); END IF; END // DELIMITER ;这个触发器在每次库存变化时自动写流水,change_qty用新旧值相减,正数入库负数出库。但要注意,3.1 节的存储过程里已经手动写了stock_log,如果再加触发器会重复记录。二选一:要么全用触发器,要么全用应用层。我一般建议课设里用触发器演示「自动化」概念,然后把存储过程里的手动插入去掉。
最后给一个验证技巧:用CHECKSUM TABLE或自己写对账 SQL,定期跑一遍库存和流水是否一致。课设答辩时现场跑这条,比任何 PPT 都有说服力:
SELECT w.product_id, w.brand, w.spec, w.stock_qty, COALESCE(SUM(s.change_qty),0) AS computed_stock FROM water_product w LEFT JOIN stock_log s ON w.product_id = s.product_id GROUP BY w.product_id HAVING w.stock_qty <> COALESCE(SUM(s.change_qty),0);如果返回空结果集,说明账实相符。这条 SQL 我每次课设验收都会让学生跑,跑不通就回去查触发器或存储过程哪里漏了。
做课设这些年,我最大的习惯是:建完表先插一批假数据,把每个查询和事务都跑一遍,别等界面写完才发现外键报错。数据库课设的分数不在界面多漂亮,而在数据能不能自圆其说。希望帮到你。
本文还有配套的精品资源,点击获取