简介:这是一份面向数据库课程设计的超市管理系统完整项目文档,适合计算机相关专业学生、需要完成数据库大作业的开发者,以及想了解 C 语言结合 MySQL 做管理系统的初学者。内容涵盖小型超市管理系统的设计理念、需求分析、开发环境、数据库表与 E-R 图、界面框架、核心代码和问题排查,能帮助读者从零理解一个数据库应用项目的完整落地过程。资源为单个 PDF 文档,压缩包仅 1 个文件,大小 555KB。文档围绕顾客、员工、管理员三类权限展开:顾客可查询商品信息,员工负责库存与销售记录,管理员可查看销售数据并管理员工信息;同时梳理了员工表、商品表、货架表、进货表、日销售量表等基本表设计,也给出 E-R 图中员工整理商品、员工记录销售、货架摆放商品的一对多关系。代码部分列出 mysql_real_connect、mysql_query、mysql_store_result、mysql_fetch_row 等关键 API 的用途,并针对主键外键选择、MFC 使用、代码整合及并发访问等问题给出排错思路,已有 3641 人浏览学习,对正在做数据库课设或想参考超市管理系统设计方案的学习者具有实用价值。
1. 为什么"超市管理系统"是数据库课程设计的样板题
这个项目是从学校小卖部场景长出来的。写这套系统的人用了 Visual Studio 2013、MySQL 和 Navicat,后台是 C 语言,界面是 MFC,最后交上去的是一份带完整 E-R 图和建表脚本的课程设计报告。超市管理系统之所以常被选作数据库课程设计和数据库原理的演练对象,在于它实体够少、关系够典型:员工、商品、货架、进货、销售,五张表就能把主外键、一对多、查询统计全部覆盖,同时又不像订单系统那样有复杂的多级关联。任何想在一周内完成"能跑通的数据库增删改查 + 三个角色界面"的人都值得把它拆一遍。下面从数据建模开始,逐步把整个系统的骨架、代码和坑位过一遍。
2. 从业务到表结构:超市管理系统的数据建模与 E-R 图
2.1 三端用户的需求如何映射到数据模型
原报告把系统分成顾客、员工、管理员三个入口,这是很标准的基于角色的访问控制思路。顾客只关心商品名称、类别和价格;员工要修改库存和货架位置,还要记录每笔销售;管理员则要员工信息和销售汇总。这三种需求落到数据库上,就是"谁对哪些表有读权限、写权限"的问题。
数据模型首先要回答的是实体有哪些。原项目最终确认了员工、商品、货架、进货、日销售量五张基本表。其中员工与商品是"整理"关系,一个员工整理多种商品,所以是一对多;员工与销售是"记录"关系,一个员工记录多条销售记录;货架与商品是"摆放"关系,一个货架摆放多种商品,也是一对多。这三个一对多关系,分别对应商品表里的员工外键、日销售量表里的员工外键,以及货架表与商品表之间通过编号关联的组合键。
这里有一个容易犯的设计错误:一开始很容易把"货架"设计成商品表的一个普通字段,里面直接写"3号货架"。但货架和商品是独立业务实体,因为"哪个商品放在哪个货架的哪个位置"属于多值信息。如果合并成字段,后续查"某货架上有什么商品"就只能写 LIKE 模糊查询,没法走索引。这一点在最初画 E-R 图时就该定下来,否则中途改表结构会牵扯到所有 C 语言代码。
2.2 五张核心表的设计与主外键取舍
按原报告里的实体描述,我整理出一套可以直接在 MySQL 5.x 上运行的建表脚本。员工表以员工编号为主键,姓名非空;商品表以商品编号为主键,其余属性均非空;货架表以编号和商品编号共同作为主键;进货表以商品编号为主键;日销售量表单独给一个自增主键便于记录流水。
员工表employee:
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| emp_id | INT | PRIMARY KEY | 员工编号,登录账号 |
| emp_name | VARCHAR(20) | NOT NULL | 员工姓名,也是初始登录名 |
| emp_pwd | VARCHAR(20) | DEFAULT NULL | 初始为空,员工登录后可自行修改 |
| position | VARCHAR(20) | DEFAULT NULL | 岗位:管理员/收银员/理货员 |
| phone | VARCHAR(20) | DEFAULT NULL | 联系方式 |
商品表product:
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| prod_id | INT | PRIMARY KEY | 商品编号 |
| prod_name | VARCHAR(50) | NOT NULL | 商品名称,顾客端搜索入口 |
| category | VARCHAR(30) | NOT NULL | 商品类别 |
| price | DECIMAL(10,2) | NOT NULL | 售价 |
| stock | INT | NOT NULL | 库存数量 |
| emp_id | INT | FOREIGN KEY | 负责整理该商品的员工 |
日销售量表daily_sales用于记录每次销售,原文叫"日销售量表",但为了支持按日期和商品编号查询,我把它设计为带自增主键的流水表,而不是每天只存一条汇总。
这里特别要说的主键选择:货架表用复合主键(shelf_id, prod_id)能保证一个货架上的商品记录唯一,但真实业务里"一个商品只放在一个货架"更常见,真正应该唯一的是prod_id。如果沿用原设计,同一个商品可以被放进多个货架,后续盘点会重复统计。我的建议是:如果只是交课程设计,复合主键可以;如果想让模型更贴近现实,就改成shelf_id主键 +prod_id外键,再对prod_id加唯一索引,并在文档里说明为什么这样改。
2.3 建表顺序与外键约束的坑
原报告明确提到"建表时对主码和外码的选取问题,由于初期没有完全考虑清楚表之间的关系而出现错误"。这个坑几乎每个做数据库课程设计的人都会踩。最常见的是:先建了带外键的商品表,再建员工表,MySQL 直接报Cannot add foreign key constraint。正确顺序是先建被引用的表,再建引用表。初始化时如果外键报错,优先检查三件事:外键指向的列在主表里是不是主键或唯一索引;两张表的字段类型是否完全一致,比如一个是 INT 一个是 VARCHAR;存储引擎是不是 InnoDB,因为 MyISAM 不强制外键。下面的脚本就是按正确顺序写的:
CREATE DATABASE IF NOT EXISTS supermarket DEFAULT CHARSET utf8mb4; USE supermarket; CREATE TABLE employee ( emp_id INT PRIMARY KEY, emp_name VARCHAR(20) NOT NULL, emp_pwd VARCHAR(20) DEFAULT NULL, position VARCHAR(20) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL ) ENGINE=InnoDB; CREATE TABLE product ( prod_id INT PRIMARY KEY, prod_name VARCHAR(50) NOT NULL, category VARCHAR(30) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL, emp_id INT, CONSTRAINT fk_product_emp FOREIGN KEY (emp_id) REFERENCES employee(emp_id) ) ENGINE=InnoDB; CREATE TABLE shelf ( shelf_id INT NOT NULL, prod_id INT NOT NULL, location VARCHAR(50), PRIMARY KEY (shelf_id, prod_id), CONSTRAINT fk_shelf_prod FOREIGN KEY (prod_id) REFERENCES product(prod_id) ) ENGINE=InnoDB; CREATE TABLE purchase ( prod_id INT PRIMARY KEY, supplier VARCHAR(50), quantity INT, purchase_date DATE, CONSTRAINT fk_purchase_prod FOREIGN KEY (prod_id) REFERENCES product(prod_id) ) ENGINE=InnoDB; CREATE TABLE daily_sales ( sale_id INT AUTO_INCREMENT PRIMARY KEY, emp_id INT NOT NULL, prod_id INT NOT NULL, quantity INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, sale_date DATE NOT NULL, CONSTRAINT fk_sales_emp FOREIGN KEY (emp_id) REFERENCES employee(emp_id), CONSTRAINT fk_sales_prod FOREIGN KEY (prod_id) REFERENCES product(prod_id) ) ENGINE=InnoDB;脚本里刻意写了ENGINE=InnoDB。原项目在 VS2013 里用 C 语言连接 MySQL,如果建表时漏掉引擎参数,默认可能落到 MyISAM,外键约束不生效,删除员工时也体验不到级联或限制效果。daily_sales使用AUTO_INCREMENT是为了让每次销售有独立流水号,方便管理员按日期区间统计。也就是表结构设计不只是为了存数据,还要为后续的增删改查和统计查询提供顺手的索引。
3. VS2013 环境配置与 C 语言操作 MySQL 的核心流程
3.1 让 VS2013 找到 mysql.h
C 语言操作 MySQL 的前提是让编译器能找到 MySQL 提供的头文件和库文件。典型做法是在 VS2013 项目属性里做两处配置:在"配置属性 -> C/C++ -> 常规 -> 附加包含目录"里加入 MySQL 安装目录下的 include 文件夹;在"链接器 -> 常规 -> 附加库目录"里加入 lib 文件夹,然后在"链接器 -> 输入 -> 附加依赖项"里写入libmysql.lib。版本不同路径会不一样,不要照抄,应该在 MySQL 安装目录下确认 include 和 lib 确实存在。
很多初学者卡在这一步,是因为 VS2013 默认按 32 位编译,而 MySQL 装的是 64 位,链接时提示找不到 libmysql.lib。解决办法是把项目平台切成 x64,或者把 MySQL 安装目录下的libmysql.dll复制到 exe 同目录。libmysql.dll是动态链接库,调试时忘记拷贝,程序启动就会提示缺失 dll。
3.2 连接数据库与执行增删改查的标准代码
原报告列出了最核心的几个 API:mysql_real_connect负责连接,mysql_query负责执行 SQL,mysql_store_result把查询结果拉到客户端,mysql_fetch_row逐行读取。把它们串起来就是完整的"连接 -> 查询 -> 取数 -> 释放"流程。下面这段代码是顾客端查询商品信息的雏形:
#include <mysql.h> #include <stdio.h> #include <string.h> int query_product(const char *name) { MYSQL *sock; MYSQL_RES *res; MYSQL_ROW row; char sql[512]; sock = mysql_init(NULL); if (sock == NULL) { return -1; } if (mysql_real_connect(sock, "localhost", "root", "123456", "supermarket", 3306, NULL, 0) == NULL) { fprintf(stderr, "connect error: %s\n", mysql_error(sock)); return -1; } // 统一客户端字符集,原项目用 GB2312 mysql_query(sock, "SET NAMES 'GB2312'"); sprintf(sql, "SELECT prod_id, prod_name, price, stock " "FROM product WHERE prod_name = '%s'", name); if (mysql_query(sock, sql) != 0) { fprintf(stderr, "query error: %s\n", mysql_error(sock)); mysql_close(sock); return -1; } res = mysql_store_result(sock); if (res == NULL) { fprintf(stderr, "store result error: %s\n", mysql_error(sock)); mysql_close(sock); return -1; } while ((row = mysql_fetch_row(res)) != NULL) { printf("%s | %s | %s | %s\n", row[0], row[1], row[2], row[3]); } mysql_free_result(res); mysql_close(sock); return 0; }这段代码的关键参数:mysql_real_connect的第一个参数是mysql_init初始化好的句柄;第二到第五个参数分别是主机名、用户名、密码、数据库名;第六个参数3306是端口;最后两个参数分别对应 unix socket 和客户端标志,都填 0 和 NULL 即可。mysql_query执行的是原生 SQL 字符串,返回 0 表示成功。mysql_store_result是一次性把所有结果读入内存,适合小数据量;如果换成海量数据,应该用mysql_use_result配合mysql_fetch_row流式读取。mysql_free_result必须调用,否则长连接服务端会内存泄漏。
3.3 中文乱码的根源:客户端字符集与 CString 的转换
原项目专门加了mysql_query(sock, "SET NAMES 'GB2312'")解决乱码,这是那个年代的常见写法。乱码的本质是数据库表字符集、客户端连接字符集、程序内部字符串编码三者不一致。在 VS2013 的 MFC 工程里,默认字符集可能是 Unicode,而 C 语言char*是 ANSI 窄字节。从mysql_fetch_row拿到的字节串直接赋给CString,MFC 会按 Unicode 理解,显示自然乱码。
常见做法是拿到row[i]后用MultiByteToWideChar显式转换,或者把 VS2013 项目字符集改成"使用多字节字符集",让CString直接以 ANSI 方式处理。但改了工程字符集后,所有依赖TCHAR宏的代码行为都会变化。另一个隐患是 SQL 字符串里的中文值:sprintf拼出来的 SQL 本身是 GB2312 编码,而 MySQL 连接设置是 utf8,查询条件里的中文就匹配不上。所以原项目保持 GB2312 设置,和 MFC 默认代码页是自洽的。
3.4 把增删改查封装成通用函数
mysql_query不仅能执行 SELECT,也能执行 INSERT、UPDATE、DELETE。员工端的商品管理、管理员端的员工信息修改,底层都是 UPDATE。常见封装是写一个execute_update(const char *sql)函数:
int execute_update(MYSQL *sock, const char *sql) { if (mysql_query(sock, sql) != 0) { fprintf(stderr, "update error: %s\nsql: %s\n", mysql_error(sock), sql); return -1; } /* 对于 INSERT/UPDATE/DELETE,查看受影响行数 */ if (mysql_affected_rows(sock) == 0) { printf("no row affected\n"); } return 0; }mysql_affected_rows返回上一条语句影响的行数。用这个函数可以处理"管理员更新员工信息,但编号不存在"的情况,页面上给出"未找到员工"的提示,而不是什么都不做。核心思路是:增删改查共用同一个执行入口,只是传入的 SQL 不同;SELECT 结果多,交给mysql_store_result;非 SELECT 语句直接看影响行数即可。
4. 三端功能实现:顾客搜索、员工管理、管理员登录的业务逻辑
4.1 顾客端:按商品名称或类别检索
顾客端界面在原项目里是最简单的,一个文本框加一个结果列表。顾客输入商品名称或类别,程序拼接 SQL 查询。为了让搜索接近真实超市,可以支持模糊匹配:
SELECT prod_id, prod_name, price, stock FROM product WHERE prod_name LIKE CONCAT('%', '可乐', '%') OR category LIKE CONCAT('%', '饮料', '%');这里用CONCAT而不是在 C 代码里直接拼%,是为了避免用户输入的单引号破坏 SQL 结构。顾客端只读,不需要事务。查询结果需要按价格排序时加ORDER BY price ASC,排序在数据库端做,不要在 C 代码里自己写冒泡排序。
4.2 员工端:个人信息、商品管理与销售记录
员工端比顾客端多了写权限。员工登录后有三个子模块:个人信息修改是 UPDATE employee;商品信息管理复用 3.4 的封装函数对 product 做增删改;销售记录是 INSERT 进 daily_sales。这里有个容易被忽略的逻辑:销售发生时必须同步扣减 product 表的 stock。原报告没有明确提,但如果不做库存联动,商品信息管理的库存就永远对不上账。
库存扣减通常写成两条语句:先查当前库存,再 UPDATE 成新库存。这两条语句之间如果没有事务,两个收银员同时卖同一件商品就会超卖。第 5 章会专门说并发,这里先按课程设计的简单实现来:UPDATE product SET stock = stock - %d WHERE prod_id = %d,把扣减下推给数据库,避免"读出来减完再写回去"的中间态。
4.3 管理员端:登录校验、员工信息增改查与销售统计
管理员界面进入前必须登录。原项目输入用户名和密码,程序去 employee 表里比对,并检查 position。朴素实现就是sprintf拼 SQL,然后看有没有返回行:
char sql[256]; sprintf(sql, "SELECT emp_id, emp_name FROM employee " "WHERE emp_name='%s' AND emp_pwd='%s' AND position='管理员'", name, pwd); mysql_query(sock, sql); res = mysql_store_result(sock); if (mysql_num_rows(res) > 0) { /* 登录成功,进入管理员界面 */ } else { printf("用户名或密码错误\n"); } mysql_free_result(res);这里有两个问题。第一,密码明文存储,课程设计可以接受,但想显得专业一点,至少用 MySQL 的SHA2做哈希。第二,name和pwd直接拼接进 SQL,输入' OR '1'='1就能绕过登录。这种注入漏洞原项目没有提及,但被评审问到的概率很高,第 5.2 节会给出修复办法。
管理员端的销售统计是报表功能。按日期查询销售情况时,SQL 这样写:
SELECT DATE_FORMAT(sale_date, '%Y-%m-%d') AS d, SUM(total_amount) AS total FROM daily_sales WHERE sale_date BETWEEN '2024-01-01' AND '2024-01-31' GROUP BY d;DATE_FORMAT把日期格式化成天,GROUP BY d按天汇总。在 C 语言中显示结果时,mysql_store_result拿到的行可以直接打印。这种聚合查询是数据库优化里的典型场景,索引应该建在sale_date上,否则数据量大了全表扫描会让界面卡顿。
4.4 MFC 界面集成中的 CString 与 char* 转换
原报告提到 CString 类型调试出错。MFC 的 CString 在 VS2013 里默认是宽字符,而 MySQL API 返回的char*是窄字符。直接赋值,CString 会把每个字节当作宽字符的高 8 位,显示成乱码。解决方法是显式转换:
CString strName; const char *name = row[1]; #ifdef _UNICODE int len = MultiByteToWideChar(CP_ACP, 0, name, -1, NULL, 0); MultiByteToWideChar(CP_ACP, 0, name, -1, strName.GetBuffer(len), len); strName.ReleaseBuffer(); #else strName = name; #endifCP_ACP表示当前 ANSI 代码页。如果 MySQL 连接设置是SET NAMES 'GB2312',那么row[1]是 GBK 编码,CP_ACP正好对应中文 Windows 的 GBK。另一种省事的方式是把项目字符集切到多字节,但那时 MFC 控件方法签名从LPCTSTR变成char*,同样需要适应。无论哪种方案,原则都是保持"程序编码 = 连接字符集 = 表字符集"三者在一条线上。
4.5 功能拆分带来的权限控制问题
三端界面在同一台机器上运行,数据库连接使用同一个 root 账号。这意味着任何一端都能执行删除员工、清空商品表的操作。如果想让系统更像生产环境,可以分别建三个 MySQL 账号:顾客端只授 SELECT;员工端授 product 和 daily_sales 的 SELECT/UPDATE/INSERT;管理员端授全部。数据库权限本身就是一层防线,比在程序里判断角色更可靠。
5. 从课程设计到可演示:SQL 注入防护、并发控制与验证技巧
5.1 并发访问未实现的隐患与补救方案
原报告最后一条写的是"还没实现并发访问"。对于单机课程设计这不算扣分项,但要清楚并发指的是什么:多个顾客或收银员同时操作时,数据库可能产生脏读、超卖、死锁。以扣库存为例,两个会话同时执行UPDATE product SET stock = stock - 1,MySQL 的行锁会让后一个会话阻塞,不会超卖。问题出在"先 SELECT 再计算再 UPDATE"的模式,两个会话读到同一个库存值,都写回减 1 后的结果,库存少减了一次。
补救方案分两档。简单做法是把"查库存+扣减+写销售"包成一个事务:
START TRANSACTION; SELECT stock FROM product WHERE prod_id = 1 FOR UPDATE; UPDATE product SET stock = stock - 1 WHERE prod_id = 1; INSERT INTO daily_sales (emp_id, prod_id, quantity, total_amount, sale_date) VALUES (1, 1, 1, 2.50, CURDATE()); COMMIT;SELECT ... FOR UPDATE会对命中行加排他锁,直到 COMMIT 才释放;另一个会话再执行同样语句会阻塞,从而避免超卖。更进阶的做法是引入 MySQL 连接池,让多线程窗口各自持有独立连接,而不是共用一个MYSQL *sock。这一步在原项目里改动较大,答辩时可以只讲设计思路。
5.2 SQL 注入防护:从拼接 SQL 改为转义输入
4.3 里的登录校验是典型注入点。修复方式有两种:最推荐用预处理语句,MySQL C API 提供mysql_stmt_prepare系列;轻量级方案是转义用户输入:
char escaped_name[128]; mysql_real_escape_string(sock, escaped_name, name, strlen(name)); sprintf(sql, "SELECT emp_id FROM employee " "WHERE emp_name='%s' AND emp_pwd='%s'", escaped_name, escaped_pwd);mysql_real_escape_string会把单引号、反斜杠等字符转义,使输入失去闭合 SQL 的能力。注意这个函数依赖当前连接句柄,因为它要参考 MySQL 的字符集设置。转义后拼 SQL 能堵住绝大多数注入,但对 LIKE 通配符%和_还有残留风险。如果顾客端支持模糊搜索,应当再配合ESCAPE关键字处理用户输入的通配符。
5.3 答辩前快速验证数据库状态的三个命令
交作业前用 Navicat 或命令行跑三个检查。第一,确认外键约束真的建上了:执行SHOW CREATE TABLE product;,看输出里有没有FOREIGN KEY。第二,验证销售统计 SQL 能返回预期行数:SELECT COUNT(*) FROM daily_sales;。第三,检查有没有残留连接导致程序第二次启动报Too many connections:执行SHOW PROCESSLIST;。如果有大量 Sleep 连接,说明程序退出时漏掉了mysql_close。
最后补充一个连老师都认的细节:每次mysql_query之后检查返回值,每次mysql_store_result之后检查是否为 NULL。原项目所有查询共用一个连接,顺序执行,如果某条 SQL 出错后没有释放结果集,后续查询会直接失败。养成这个习惯,演示时就不会出现点按钮就崩溃。用mysqladmin status观察Threads_connected,也能直观证明系统在多窗口并发打开时不再只是单线程玩具。
本文还有配套的精品资源,点击获取