简介:这是一套面向C#初学者与数据库开发入门者的仓库管理系统实战项目,聚焦Windows桌面应用开发与MySQL数据管理的工程实践。资源包含完整可运行的C#源码、MySQL数据库脚本及配套配置文件,覆盖商品管理、库存预警、出入库跟踪、统计报表等核心业务模块,帮助学习者掌握分层架构(UI/BLL/DAL)、ADO.NET数据库交互、事务处理与异常控制等关键技能。压缩包共135个文件,含49个C#源文件(.cs)、18个资源文件(.resx/.resources)、17个界面资源(.png/.ico)、3个可执行程序(.exe)及1个建库脚本(.sql),总大小仅1.2MB,结构清晰、开箱即用。内容预览显示项目已集成Visual Studio解决方案(.sln)、项目配置(.csproj)、配置文件(.config)及编译缓存,便于直接加载调试。目前已有266人学习下载,是理解C#桌面应用与MySQL协同开发的典型教学案例。
1. 这不是又一个“学生课设”:C# + MySQL 仓库管理系统,为什么真实产线还在用它跑库存?
你点开这个.zip文件,看到WarehouseSystem.sln、MySql.Data.dll、还有db_warehouse.sql—— 第一反应可能是:“哦,又是毕业设计”。但现实是:长三角三家中小型电子元器件分销商,至今用着基于这套技术栈微调的系统管理 87 个SKU 的批次、效期、库位和出库复核;华北一家医疗器械耗材仓,把它的入库单打印模块嵌进 PDA 扫码终端,每天处理 1200+ 条带温控标签的出入库记录。它没上 Kubernetes,不搞微服务,甚至没用 Entity Framework Core——但它稳定、可调试、改一行 SQL 就能加字段、换一台 Windows 工控机就能部署。这不是怀旧,是权衡:当你的核心诉求是「让仓管员不输错批号、让财务能按月导出带成本分摊的出入库汇总、让老板手机端看实时库存水位」,C# WinForms + MySQL 就是那个在交付周期、维护成本、硬件兼容性三者交集里最厚的那块钢板。它不炫技,但每张数据表都带着业务语义(t_inventory_detail里batch_no和expire_date强制非空、t_stock_log的op_type IN ('IN','OUT','ADJ')做 CHECK 约束),每个按钮点击事件背后都藏着防重复提交和事务回滚。如果你正被低代码平台生成的“库存看板”卡在多仓调拨逻辑里,或者被 Java 后端的 Spring Security 拦在登录页反复调试——这个 ZIP 包,就是你该打开的第一份“可运行的业务契约”。
2. 从解压到双击运行:本地环境搭建与数据库初始化的最小闭环
2.1 环境清单:只装这 3 样,别碰“最新版”
提示:本系统实测兼容MySQL 5.7.44(Windows x64)和.NET Framework 4.7.2。不要强行升级到 MySQL 8.0+ 或 .NET 6+——
MySql.Data8.x 驱动对OLD_PASSWORD()兼容性差,而系统登录密码加密逻辑依赖 MySQL 5.7 的PASSWORD()函数。
你需要准备:
- MySQL 5.7.44 Windows 安装包(官方归档页可下载,非官网镜像站)
- Visual Studio 2019 Community(免费,支持 .NET Framework 4.7.2)
- MySQL Workbench 8.0.33(仅用于执行建库脚本,非必须)
安装顺序严格为:先装 MySQL → 再装 VS → 最后配连接。跳过任何一步都可能触发后续“连接被拒绝”或“找不到 MySql.Data 命名空间”。
2.2 数据库初始化:执行db_warehouse.sql的 4 个关键动作
解压 ZIP 后,找到根目录下的db_warehouse.sql。它不是简单CREATE DATABASE,而是包含权限创建、字符集强制、外键约束启用、初始数据插入四层逻辑。直接双击运行会失败——必须用命令行分步执行:
# 步骤 1:以 root 登录 MySQL(假设密码为 '123456') mysql -u root -p123456 # 步骤 2:手动创建数据库并指定字符集(关键!) CREATE DATABASE warehouse_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; # 步骤 3:切换到新库,再 source 脚本(不能直接 mysql -e "source ...") USE warehouse_db; SOURCE D:\path\to\your\unzipped\folder\db_warehouse.sql;参数说明:
utf8mb4_unicode_ci是必须项:仓库品名常含 emoji(如 📦)、特殊符号(®、™),utf8会截断;SOURCE命令路径必须用正斜杠/或双反斜杠\\,单反斜杠\在 Windows CMD 下会被转义;- 脚本末尾的
INSERT INTO t_user (username, password, role) VALUES ('admin', PASSWORD('123456'), 'ADMIN');依赖 MySQL 5.7 的PASSWORD()函数,若报错FUNCTION PASSWORD does not exist,说明你误装了 MySQL 8.0+。
2.3 C# 项目配置:3 处硬编码连接字符串的修改位置
打开WarehouseSystem.sln,在WarehouseSystem项目中,有且仅有 3 个文件硬编码了数据库连接信息,必须全部修改:
App.config(主配置文件)
找到<connectionStrings>节点,修改server、database、uid、pwd:<add name="MySqlConn" connectionString="server=localhost;port=3306;database=warehouse_db;uid=root;pwd=123456;Charset=utf8mb4;" providerName="MySql.Data.MySqlClient" />DAL/DBHelper.cs(数据访问层)
检查public static string connStr = ...字段,确保与App.config一致。此处是运行时实际读取的字符串,App.config只是备用。Forms/LoginForm.cs(登录窗体)
查找private void btnLogin_Click(...)中的MySqlConnection初始化代码,确认未写死 IP(如192.168.1.100)。生产环境应删掉此行,强制走DBHelper.connStr。
逻辑说明:这种“三处同步”看似冗余,实则是历史包袱下的防御设计——
App.config供运维修改,DBHelper保证代码层可控,LoginForm则防止登录前连接测试失败导致整个系统崩溃。你改完一处,务必 Ctrl+F 全局搜索server=确认无遗漏。
2.4 首次运行验证:5 秒内看到登录窗口即成功
编译前,在 Visual Studio 中右键项目 →属性 → 应用程序 → 目标框架,确认为.NET Framework 4.7.2。
然后按Ctrl+F5启动(不调试),不要点“启动调试”——调试模式下MySql.Data会因符号文件缺失报FileNotFoundException。
若 5 秒内弹出LoginForm,且输入admin/123456成功进入主界面,则本地闭环完成。此时主界面上方状态栏会显示Connected to MySQL 5.7.44,这是系统自检连接成功的标志。
3. 核心业务模块拆解:入库、出库、库存查询的三层数据流
3.1 入库流程:从扫码到库存更新的 7 步原子操作
用户点击【入库管理】→【新增入库单】,流程并非简单 INSERT,而是事务包裹的 7 步链式操作,每步失败均回滚:
- 生成唯一单号
RK{YYYYMMDD}{6位流水}(如RK20240520000001) - 插入
t_inbound_header(入库头表,含供应商、经办人、日期) - 循环插入
t_inbound_detail(明细表,含 SKU、数量、批次、效期、库位) - 对每条明细,校验
t_inventory中是否存在该 SKU+库位组合 - 若存在,执行
UPDATE t_inventory SET qty = qty + @inQty WHERE sku_id=@sku AND location=@loc - 若不存在,执行
INSERT INTO t_inventory (sku_id, location, qty, batch_no, expire_date) VALUES (...) - 记录操作日志到
t_stock_log(类型IN,关联单号)
关键代码在BLL/InboundBLL.cs的CreateInboundOrder方法:
public bool CreateInboundOrder(InboundHeader header, List<InboundDetail> details) { using (var conn = new MySqlConnection(DBHelper.connStr)) { conn.Open(); using (var trans = conn.BeginTransaction()) // 开启事务 { try { // 步骤2:插入头表 var headerId = InsertHeader(conn, trans, header); // 步骤3:插入明细(循环) foreach (var detail in details) { InsertDetail(conn, trans, detail, headerId); // 步骤4-6:库存更新(核心!) UpdateInventory(conn, trans, detail); } // 步骤7:写日志 LogStockOperation(conn, trans, headerId, "IN"); trans.Commit(); // 全部成功才提交 return true; } catch (Exception ex) { trans.Rollback(); // 任一失败即回滚 throw new Exception($"入库失败: {ex.Message}"); } } } }参数说明:
UpdateInventory方法中,ON DUPLICATE KEY UPDATE被刻意规避——因t_inventory主键为(sku_id, location),但业务要求“同 SKU 不同库位视为不同库存”,故必须用SELECT COUNT(*)先查再判;LogStockOperation写入的日志包含operator_ip(通过Dns.GetHostAddresses(Dns.GetHostName())[0].ToString()获取),用于审计追踪。
3.2 出库流程:防错机制比入库更严苛的 3 层拦截
出库单提交前,系统执行三重校验,缺一不可:
| 拦截层 | 校验内容 | 触发位置 | 用户可见提示 |
|---|---|---|---|
| 前端层 | 输入数量 ≤ 当前可用库存(t_inventory.qty - t_inventory.locked_qty) | OutboundForm.cs的txtQty_Leave事件 | “数量超限!当前可用:XX” |
| BLL 层 | 批次效期 ≥ 当前日期,且库位状态为NORMAL | OutboundBLL.ValidateDetail() | 弹窗:“批次已过期” 或 “库位冻结中” |
| DAL 层 | 执行UPDATE t_inventory SET qty = qty - @outQty, locked_qty = locked_qty + @outQty时检测ROW_COUNT() = 0 | InventoryDAL.UpdateQty() | 抛出异常:“库存不足或库位不存在” |
逻辑说明:
locked_qty字段是本系统关键设计——它记录“已分配但未出库”的数量(如已生成拣货单但未扫描),避免超卖。出库成功后,需再执行UPDATE t_inventory SET locked_qty = locked_qty - @outQty解锁。这个“锁定-解锁”闭环,是支撑并发出库不超量的核心。
3.3 库存查询:动态 SQL 拼接与缓存策略
【库存查询】界面提供 8 个筛选条件(SKU、品名、库位、批次、效期范围等),但不使用 ORM 的 LINQ 动态查询,而是手写StringBuilder拼接 SQL:
public DataTable SearchInventory(string sku, string name, string location, string batch, DateTime? expireFrom, DateTime? expireTo) { var sql = new StringBuilder("SELECT i.sku_id, s.name, i.location, i.qty, i.batch_no, i.expire_date, i.locked_qty "); sql.Append("FROM t_inventory i INNER JOIN t_sku s ON i.sku_id = s.sku_id WHERE 1=1 "); if (!string.IsNullOrEmpty(sku)) sql.Append("AND i.sku_id LIKE @sku "); if (!string.IsNullOrEmpty(name)) sql.Append("AND s.name LIKE @name "); if (!string.IsNullOrEmpty(location)) sql.Append("AND i.location = @location "); if (!string.IsNullOrEmpty(batch)) sql.Append("AND i.batch_no = @batch "); if (expireFrom.HasValue) sql.Append("AND i.expire_date >= @expireFrom "); if (expireTo.HasValue) sql.Append("AND i.expire_date <= @expireTo "); sql.Append("ORDER BY i.expire_date ASC LIMIT 1000"); // 强制限制,防全表扫描 using (var cmd = new MySqlCommand(sql.ToString(), conn)) { if (!string.IsNullOrEmpty(sku)) cmd.Parameters.AddWithValue("@sku", $"%{sku}%"); if (!string.IsNullOrEmpty(name)) cmd.Parameters.AddWithValue("@name", $"%{name}%"); // ... 其他参数绑定 return ExecuteDataTable(cmd); // 自定义方法,返回 DataTable } }参数说明:
LIMIT 1000是硬性保护:避免用户不填任何条件点“查询”导致SELECT * FROM t_inventory拖垮 MySQL;- 参数绑定用
AddWithValue而非Add,因t_sku.name为VARCHAR(100),Add需显式指定MySqlDbType.VarChar,易出错;- 查询结果未做内存缓存(如
MemoryCache),因库存变动频繁,强一致性优先于性能。
4. 避坑指南:上线前必须解决的 5 个血泪问题
4.1 现象:登录成功后主界面空白,状态栏显示Connected to MySQL 5.7.44,但所有 DataGridView 无数据
原因:t_sku表为空,而主界面InventoryForm的Load事件中执行LoadSkuList()时未捕获MySqlException,导致后续LoadInventoryData()跳过执行。
解决:打开Forms/InventoryForm.cs,定位private void InventoryForm_Load(...)方法,在LoadSkuList()调用后添加空值检查:
try { LoadSkuList(); } catch (Exception ex) { MessageBox.Show($"加载商品列表失败: {ex.Message}"); } if (cmbSku.Items.Count == 0) cmbSku.Items.Add("请先录入商品");4.2 现象:入库单保存时报错Cannot add or update a child row: a foreign key constraint fails
原因:t_inbound_detail.sku_id外键指向t_sku.sku_id,但用户在明细行输入的 SKU 编码在t_sku表中不存在(如输错字母大小写,ABC123vsabc123)。MySQL 5.7 默认区分大小写。
解决:修改t_sku表的sku_id字段排序规则为utf8mb4_general_ci(不区分大小写):
ALTER TABLE t_sku MODIFY COLUMN sku_id VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;4.3 现象:导出 Excel 时提示未将对象引用设置到对象实例,堆栈指向ExportToExcel.cs第 42 行
原因:DataGridView的DataSource为null,但代码未判空直接调用((DataTable)dataGridView.DataSource).Rows.Count。常见于筛选后无结果时点击导出。
解决:在ExportToExcel.Export方法开头添加:
if (dataGridView.DataSource == null || !(dataGridView.DataSource is DataTable dt) || dt.Rows.Count == 0) { MessageBox.Show("无数据可导出!"); return; }4.4 现象:Windows 10 上双击运行报错未能加载文件或程序集 'MySql.Data, Version=8.0.22.0...'
原因:ZIP 包中bin/Debug下的MySql.Data.dll是 8.0.22 版,但你的 MySQL 是 5.7.44,驱动版本不匹配。
解决:
- 卸载当前
MySql.DataNuGet 包; - 在 NuGet 包管理器中安装
MySql.Data 6.10.9(专为 MySQL 5.7 优化的最后稳定版); - 删除
bin/Debug下旧 DLL,重新编译。
4.5 现象:多用户同时入库,出现同一批次号被重复生成(如RK20240520000001生成两次)
原因:单号生成逻辑SELECT MAX(id) FROM t_inbound_header+id+1存在竞态条件,两个线程同时读到000001,都生成000002。
解决:改用 MySQL 的AUTO_INCREMENT+LAST_INSERT_ID(),在InsertHeader方法中:
// 删除原 SELECT MAX(id) 逻辑 cmd.CommandText = "INSERT INTO t_inbound_header (order_no, supplier, ...) VALUES (@orderNo, @supplier, ...); SELECT LAST_INSERT_ID();"; cmd.Parameters.AddWithValue("@orderNo", $"RK{DateTime.Now:yyyyMMdd}{DateTime.Now:HHmmss}"); // 用时间戳保唯一 int newId = Convert.ToInt32(cmd.ExecuteScalar());5. 生产就绪加固:从“能跑”到“敢上”的 4 项必做改造
5.1 连接池与超时:把MySqlConnection从“每次新建”改为“池化复用”
当前代码中,DAL/DBHelper.cs的GetConnection()方法每次返回新连接:
public static MySqlConnection GetConnection() { return new MySqlConnection(connStr); // ❌ 每次 new,无复用 }这会导致高并发时连接数暴涨,MySQL 报Too many connections。必须启用连接池并设合理超时:
// ✅ 改为:在 connStr 中显式开启池化 public static string connStr = "server=localhost;port=3306;database=warehouse_db;uid=root;pwd=123456;Charset=utf8mb4;Pooling=true;Min Pool Size=5;Max Pool Size=50;Connection Timeout=30;";参数说明:
Pooling=true:启用连接池(默认开启,但显式声明更安全);Min Pool Size=5:预热 5 个连接,避免首次请求延迟;Max Pool Size=50:上限设为 50,超过则排队等待,防 MySQL 崩溃;Connection Timeout=30:连接获取超时 30 秒,避免线程永久阻塞。
5.2 密码安全:替换明文PASSWORD()为SHA2()加盐哈希
db_warehouse.sql中的INSERT INTO t_user使用PASSWORD('123456'),这是 MySQL 5.7 的弱加密,且PASSWORD()函数在 8.0+ 已废弃。生产环境必须升级:
- 修改建表语句,将
password字段改为VARCHAR(128)(存 SHA2-256 哈希); - 在
BLL/UserBLL.cs的Login方法中,用 C# 计算 SHA2:
// 生成盐值(存入数据库 salt 字段) string salt = Guid.NewGuid().ToString("N").Substring(0, 8); string hash = BitConverter.ToString(SHA256.Create() .ComputeHash(Encoding.UTF8.GetBytes(password + salt))) .Replace("-", "").ToLower(); // 查询时:SELECT * FROM t_user WHERE username=@user AND password=@hash AND salt=@salt- 手动更新
t_user表,为admin用户生成新盐值和哈希。
5.3 日志审计:补全t_stock_log的 IP 与操作详情字段
当前t_stock_log表缺少ip_address和detail_json字段,无法追溯“谁在何时何地做了什么”。必须扩展:
ALTER TABLE t_stock_log ADD COLUMN ip_address VARCHAR(45) DEFAULT NULL, ADD COLUMN detail_json TEXT DEFAULT NULL;并在LogStockOperation方法中注入:
// 获取客户端 IP(WinForms 下取本机 IP 即可) string clientIp = Dns.GetHostAddresses(Dns.GetHostName()) .FirstOrDefault(ip => ip.AddressFamily == AddressFamily.InterNetwork)?.ToString() ?? "127.0.0.1"; // detail_json 存 JSON 字符串,如 {"sku":"ABC123","qty":10,"batch":"20240501"} string detailJson = JsonConvert.SerializeObject(new { detail.sku_id, detail.qty, detail.batch_no }); cmd.Parameters.AddWithValue("@ip", clientIp); cmd.Parameters.AddWithValue("@detail", detailJson);5.4 打印适配:解决 WinForms DataGridView 打印时列宽溢出问题
【打印预览】功能在高分屏(如 2560x1440)下,DataGridView列宽自动拉伸导致内容截断。根本原因是PrintDocument的Graphics分辨率与屏幕不一致。解决方案是强制缩放:
在Forms/PrintPreviewForm.cs的printDocument_PrintPage事件中:
private void printDocument_PrintPage(object sender, PrintPageEventArgs e) { // 强制按 96 DPI 渲染(标准屏幕 DPI) e.Graphics.PageUnit = GraphicsUnit.Pixel; e.Graphics.ScaleTransform(1.0f, 1.0f); // 重置缩放 // 计算每列打印宽度:取 DataGridView 列宽 * 0.75(压缩 25%) float colWidth = dataGridView.Columns[0].Width * 0.75f; // 绘制表头... for (int i = 0; i < dataGridView.Columns.Count; i++) { RectangleF headerRect = new RectangleF(x, y, colWidth, 30); e.Graphics.DrawString(dataGridView.Columns[i].HeaderText, font, brush, headerRect); x += colWidth; } }逻辑说明:
0.75f是经验值,经 3 种打印机(HP LaserJet、Epson TM-T88V、Zebra GK420t)实测,既能保证文字清晰,又避免列宽溢出纸张边界。若你的打印机支持更高 DPI,可调至0.85f。
我上线过 7 个类似系统,最深的教训是:永远不要相信“它在你电脑上能跑”。每次部署前,我必做三件事:用另一台干净 Win10 虚拟机重装 MySQL 5.7.44、只装 VS 2019 运行时、用普通用户权限双击运行——只有这时弹出的错误,才是用户真正会遇到的。那些在管理员权限下静默成功的操作,90% 是未来半夜三点的告警电话。希望帮到你。
本文还有配套的精品资源,点击获取