简介:这是一套面向C#初学者与数据库课程实践者的仓库管理系统完整项目源码,配套MySQL数据库文件,可用于课程设计、毕业设计或自学练手。系统覆盖物品入库、出库、查询、统计等日常作业,并实现按名称、类别、入库时间、库存量、供应商、客户等八种查询场景,同时包含用户登录验证、权限管理与参数化查询等安全机制。压缩包共163个文件,约1.22MB,以49个cs源代码文件、18个resx资源文件、18个png界面截图、27个zbak备份文件及sql数据库脚本、sln解决方案、csproj工程文件为主,另附README.md安装说明与查询案例文档。目前已有29人学习下载。读者可从中获得完整的C#面向对象架构范例、MySQL表结构与视图存储过程设计、Windows Forms界面实现以及防SQL注入的编码思路,适合对照源码理解业务逻辑与数据库交互流程。
1. 基于C#的仓库管理系统:从建库到跑通入库出库的完整落地路径
很多做 C# 上位机或者企业内训的同行,接到的第一个带数据库的完整项目往往就是仓库管理系统。它不像纯算法项目那样玄学,也不像纯前端那样容易翻车,但真正要把「基于C#的仓库管理系统(含MySQL数据库文件)」跑起来,新手最容易卡在三个地方:MySQL 装完连不上、C# 连 MySQL 的驱动选错、数据库文件导入后中文乱码。这篇笔记就按我实际带人做项目的顺序,把选型理由、建库脚本、C# 数据访问层、入库出库核心逻辑和几个血泪坑一次讲清楚。适合刚学完 C# 基础语法、想找一个能写进简历的完整项目练手的开发者,也适合需要快速给内部搭一套轻量仓储工具的工程师。整套方案不依赖任何云服务,本地 MySQL 加一个 WinForms 或 WPF 客户端就能跑通。
2. 环境选型与数据库文件落地:MySQL 8.0 还是 5.7,C# 驱动怎么挑
2.1 为什么这套系统我优先推荐 MySQL 8.0 而不是 5.7
热词里反复出现「mysql 5.7.44 安装过程详细」和「mysql安装教程8.0」,说明很多人还在纠结版本。我的实际经验是:新项目直接上 MySQL 8.0,老机器或者公司强制要求兼容旧环境才退回 5.7。原因有三个。第一,8.0 默认字符集是 utf8mb4,仓库管理系统里商品名称、供应商备注经常出现生僻字或者 emoji,5.7 默认 latin1 或 utf8 三字节,导入含四字节字符的数据库文件时直接报错。第二,8.0 的窗口函数和 CTE 在写库存流水统计时非常顺手,比如按商品分组取最近一次入库记录,5.7 得绕子查询。第三,8.0 的安装器在 Windows 10 上已经比较稳定,热词里「mysql在windows10上怎么安装」的痛点主要集中在 5.7 的 zip 解压版手动配置 my.ini。
安装时记住两个关键点:端口保持 3306,认证方式选Use Legacy Authentication Method或者装完执行ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';。因为 C# 的 MySQL 驱动对 caching_sha2_password 的支持在旧版本 Connector/NET 里会报Authentication method 'caching_sha2_password' not supported,这个坑后面避坑章节还会细说。
2.2 数据库文件怎么导入:建库脚本与三个必改参数
所谓「含MySQL数据库文件」,通常是一个.sql导出文件。拿到后不要直接双击,先建库再导入。下面是我常用的建库和导入命令,在 MySQL 命令行或者 Navicat 查询窗口执行都行。
-- 创建仓库管理系统专用数据库,字符集必须 utf8mb4 CREATE DATABASE warehouse_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; -- 切换到该库 USE warehouse_db; -- 导入前先确认 sql 文件里的建表语句没有指定其他库名 -- 如果有,用文本编辑器把 CREATE DATABASE 和 USE 语句删掉或改掉导入用命令行更可控:
# 在 Windows 命令行中,进入 MySQL 的 bin 目录 mysql -u root -p warehouse_db < D:\warehouse_db.sql执行后如果报ERROR 1273 (HY000): Unknown collation: 'utf8mb4_0900_ai_ci',说明导出文件来自 MySQL 8.0,而你的目标库是 5.7。解决办法是把 sql 文件里的utf8mb4_0900_ai_ci全部替换成utf8mb4_general_ci,再重新导入。这个替换用记事本的替换功能就能做,但注意别把数据内容里的同名字符串误替换,最好用支持正则的编辑器限定在COLLATE后面。
导入完成后执行SHOW TABLES;应该能看到product、inventory、inbound_record、outbound_record、user这几张核心表。如果表数量不对,检查 sql 文件是否被截断,尤其是用 phpMyAdmin 导出的文件有时会因为超时只导出一半。
2.3 C# 侧驱动选择:Connector/NET 还是 MySqlConnector
这是新手最容易翻车的地方。Visual Studio 的 NuGet 里搜MySql会出来两个包:MySql.Data(Oracle 官方 Connector/NET)和MySqlConnector(社区异步驱动)。我的建议是:WinForms 同步操作为主的小项目用MySql.Data就行,教程多、资料全;如果打算用 async/await 写 WPF 或者后续要上 ASP.NET Core,直接选MySqlConnector,它的异步是真异步,不会像MySql.Data那样在 UI 线程上假死。
安装命令:
# 在 Visual Studio 的包管理器控制台执行 Install-Package MySql.Data -Version 8.0.33或者用 .NET CLI:
dotnet add package MySql.Data --version 8.0.33版本号不要盲目追最新,8.0.33 是我在多个项目里验证过和 MySQL 8.0 服务端配合稳定的版本。装完后在App.config或appsettings.json里配置连接字符串:
<connectionStrings> <add name="WarehouseConn" connectionString="server=localhost;port=3306;database=warehouse_db;uid=root;pwd=你的密码;charset=utf8mb4;SslMode=none;" providerName="MySql.Data.MySqlClient"/> </connectionStrings>SslMode=none在本地开发时能避免自签名证书报错,生产环境如果 MySQL 开了 SSL 再改成Preferred。charset=utf8mb4必须写,否则 C# 写入的中文在数据库里显示成问号。
3. C# 数据访问层与入库出库核心逻辑:从连接封装到事务扣减
3.1 封装一个不翻车的 MySQL 帮助类
不管用 WinForms 还是 WPF,数据访问层都建议单独放一个DAL文件夹。下面这个MySqlHelper类是我用了很多项目的精简版,核心是把连接创建、参数化查询和事务入口收在一起。
using MySql.Data.MySqlClient; using System; using System.Configuration; using System.Data; namespace Warehouse.DAL { public static class MySqlHelper { // 从配置文件读取连接字符串,避免硬编码 private static readonly string ConnStr = ConfigurationManager.ConnectionStrings["WarehouseConn"].ConnectionString; // 创建并打开连接,调用方负责 using 释放 public static MySqlConnection GetConnection() { var conn = new MySqlConnection(ConnStr); conn.Open(); return conn; } // 执行增删改,返回受影响行数 public static int ExecuteNonQuery(string sql, params MySqlParameter[] parameters) { using (var conn = GetConnection()) using (var cmd = new MySqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); return cmd.ExecuteNonQuery(); } } // 执行查询,返回 DataTable,适合绑定 DataGridView public static DataTable ExecuteQuery(string sql, params MySqlParameter[] parameters) { using (var conn = GetConnection()) using (var cmd = new MySqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); using (var adapter = new MySqlDataAdapter(cmd)) { var dt = new DataTable(); adapter.Fill(dt); return dt; } } } // 执行标量,用于 COUNT、SUM 等 public static object ExecuteScalar(string sql, params MySqlParameter[] parameters) { using (var conn = GetConnection()) using (var cmd = new MySqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); return cmd.ExecuteScalar(); } } } }逻辑说明:GetConnection每次新建连接,依赖 MySQL 的连接池,不要自己写单例连接,否则多线程下会出Connection is already in use。ExecuteNonQuery和ExecuteQuery都用了using确保连接释放。参数用params MySqlParameter[]是为了强制调用方走参数化,杜绝 SQL 拼接。
参数说明:连接字符串里的SslMode=none只在本地开发用;charset=utf8mb4必须保留;如果数据库端口不是 3306,改port值。
3.2 入库操作:事务里同时写流水和更新库存
入库不是简单插一条记录,它要同时做两件事:往inbound_record插流水,往inventory增加对应商品库存。这两步必须在一个事务里,否则插了流水没加库存,盘点时就是一笔糊涂账。
public bool Inbound(int productId, int quantity, string operatorName) { using (var conn = MySqlHelper.GetConnection()) using (var tran = conn.BeginTransaction()) { try { // 第一步:插入入库流水 string sqlRecord = @"INSERT INTO inbound_record (product_id, quantity, operator, inbound_time) VALUES (@pid, @qty, @op, NOW())"; using (var cmd = new MySqlCommand(sqlRecord, conn, tran)) { cmd.Parameters.AddWithValue("@pid", productId); cmd.Parameters.AddWithValue("@qty", quantity); cmd.Parameters.AddWithValue("@op", operatorName); cmd.ExecuteNonQuery(); } // 第二步:更新库存,如果该商品还没有库存记录则插入 string sqlStock = @"INSERT INTO inventory (product_id, stock_qty) VALUES (@pid, @qty) ON DUPLICATE KEY UPDATE stock_qty = stock_qty + @qty"; using (var cmd = new MySqlCommand(sqlStock, conn, tran)) { cmd.Parameters.AddWithValue("@pid", productId); cmd.Parameters.AddWithValue("@qty", quantity); cmd.ExecuteNonQuery(); } tran.Commit(); return true; } catch (Exception ex) { tran.Rollback(); // 实际项目里写日志,这里简单抛出 throw new Exception("入库失败:" + ex.Message); } } }逻辑说明:ON DUPLICATE KEY UPDATE依赖inventory表的product_id上有唯一索引或主键。如果建表时没加,这个语句会变成一直插入新行,库存就错了。执行前先确认SHOW INDEX FROM inventory;里有product_id的唯一索引。
参数说明:quantity建议在业务层校验大于 0,数据库层面可以加CHECK (quantity > 0),但 MySQL 5.7 会忽略 CHECK,8.0 才真正生效。operatorName从登录用户会话里取,不要从界面文本框直接拿,否则谁都能改。
3.3 出库操作:先查库存再扣减,防止负库存
出库比入库多一步校验:当前库存是否足够。这个校验必须在事务里做,并且用SELECT ... FOR UPDATE锁住那一行,否则两个出库单同时提交会把库存扣成负数。
public bool Outbound(int productId, int quantity, string operatorName) { using (var conn = MySqlHelper.GetConnection()) using (var tran = conn.BeginTransaction()) { try { // 锁定该商品库存行,防止并发扣减 string sqlCheck = "SELECT stock_qty FROM inventory WHERE product_id = @pid FOR UPDATE"; int currentStock; using (var cmd = new MySqlCommand(sqlCheck, conn, tran)) { cmd.Parameters.AddWithValue("@pid", productId); var result = cmd.ExecuteScalar(); if (result == null) throw new Exception("该商品没有库存记录,无法出库"); currentStock = Convert.ToInt32(result); } if (currentStock < quantity) throw new Exception($"库存不足,当前库存 {currentStock},请求出库 {quantity}"); // 扣减库存 string sqlUpdate = "UPDATE inventory SET stock_qty = stock_qty - @qty WHERE product_id = @pid"; using (var cmd = new MySqlCommand(sqlUpdate, conn, tran)) { cmd.Parameters.AddWithValue("@pid", productId); cmd.Parameters.AddWithValue("@qty", quantity); cmd.ExecuteNonQuery(); } // 写出库流水 string sqlRecord = @"INSERT INTO outbound_record (product_id, quantity, operator, outbound_time) VALUES (@pid, @qty, @op, NOW())"; using (var cmd = new MySqlCommand(sqlRecord, conn, tran)) { cmd.Parameters.AddWithValue("@pid", productId); cmd.Parameters.AddWithValue("@qty", quantity); cmd.Parameters.AddWithValue("@op", operatorName); cmd.ExecuteNonQuery(); } tran.Commit(); return true; } catch { tran.Rollback(); throw; } } }逻辑说明:FOR UPDATE只在事务里生效,所以BeginTransaction必须在查询之前。如果库存表数据量很大,这个行锁会影响并发,但仓库管理系统的出库频率通常不高,可以接受。真正高并发的场景会把库存扣减放到 Redis 里预扣,但那是另一个话题了。
参数说明:productId和quantity都走参数化,不要用字符串拼接。operatorName同样来自登录态。
3.4 用 DataGridView 绑定库存列表的完整写法
WinForms 里展示库存最省事的方式是DataGridView加DataTable。下面这段代码放在窗体加载事件里,配合一个刷新按钮。
private void LoadInventory() { string sql = @"SELECT p.product_code AS 商品编码, p.product_name AS 商品名称, p.unit AS 单位, IFNULL(i.stock_qty, 0) AS 当前库存 FROM product p LEFT JOIN inventory i ON p.product_id = i.product_id ORDER BY p.product_code"; DataTable dt = MySqlHelper.ExecuteQuery(sql); dataGridView1.DataSource = dt; dataGridView1.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill; }逻辑说明:用LEFT JOIN保证没有库存记录的商品也能显示出来,库存显示 0。IFNULL是 MySQL 的函数,等价于 C# 里的空合并。ORDER BY按商品编码排序,方便仓管对账。
参数说明:AutoSizeColumnsMode.Fill让列宽自适应窗体,如果列太多出现横向滚动条,改成DisplayedCells并手动设置每列Width。
4. 避坑与排查:连接失败、中文乱码、事务不生效的 5 个真实记录
4.1 现象:C# 报 Authentication method 'caching_sha2_password' not supported
原因:MySQL 8.0 默认认证插件是caching_sha2_password,而旧版MySql.Data驱动不支持。解决:要么升级MySql.Data到 8.0.33 以上,要么在 MySQL 里执行ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';然后FLUSH PRIVILEGES;。我一般选后者,因为改驱动版本可能引入其他兼容问题。
4.2 现象:数据库里中文显示成问号,或者 C# 读取出来是乱码
原因:建库时字符集不是 utf8mb4,或者连接字符串没写charset=utf8mb4。解决:先SHOW VARIABLES LIKE 'character_set%';确认服务端字符集,如果是 latin1,需要改my.ini里的character-set-server=utf8mb4并重启 MySQL 服务。连接字符串里charset=utf8mb4必须加,缺一不可。已经存进去的问号数据救不回来,只能重新导入。
4.3 现象:入库流水插入了,但库存没变
原因:事务没有提交,或者inventory表缺少product_id的唯一索引导致ON DUPLICATE KEY UPDATE没按预期更新。解决:检查代码里tran.Commit()是否执行到;执行SHOW INDEX FROM inventory;确认product_id有唯一索引,没有就ALTER TABLE inventory ADD UNIQUE INDEX uk_product (product_id);。注意加索引前先清理重复数据,否则加不上。
4.4 现象:出库时两个客户端同时操作,库存扣成负数
原因:没有用FOR UPDATE锁行,或者锁行和扣减不在同一个事务里。解决:确保BeginTransaction在SELECT ... FOR UPDATE之前,并且扣减和流水插入都在同一个conn和tran上。如果用了连接池,注意MySqlHelper.GetConnection()每次返回的是不同连接,事务必须绑定同一个连接对象。
4.5 现象:导入 sql 文件时报 Unknown collation 'utf8mb4_0900_ai_ci'
原因:导出文件来自 MySQL 8.0,目标库是 5.7。解决:用文本编辑器把 sql 文件里的utf8mb4_0900_ai_ci全部替换为utf8mb4_general_ci,utf8mb4_0900_ai_ci是 8.0 特有的排序规则。替换后重新导入。如果文件很大,用sed命令:sed -i 's/utf8mb4_0900_ai_ci/utf8mb4_general_ci/g' warehouse_db.sql。
5. 进阶技巧:用存储过程做月度库存汇总与 C# 调用
5.1 为什么把汇总逻辑放进存储过程
仓库管理系统跑一段时间后,老板会要「这个月每个商品入了多少、出了多少、结存多少」。如果在 C# 里用多次查询拼结果,代码又长又容易在并发时读到不一致的数据。把汇总做成存储过程,一次调用返回结果集,既快又稳。MySQL 存储过程在热词里也被提到,这里给一个实际能用的版本。
DELIMITER // CREATE PROCEDURE sp_monthly_summary(IN p_year INT, IN p_month INT) BEGIN SELECT p.product_code AS 商品编码, p.product_name AS 商品名称, IFNULL(SUM(CASE WHEN r.type = 'IN' THEN r.quantity END), 0) AS 入库合计, IFNULL(SUM(CASE WHEN r.type = 'OUT' THEN r.quantity END), 0) AS 出库合计, IFNULL(SUM(CASE WHEN r.type = 'IN' THEN r.quantity ELSE -r.quantity END), 0) AS 结存 FROM product p LEFT JOIN ( SELECT product_id, quantity, 'IN' AS type, inbound_time AS op_time FROM inbound_record UNION ALL SELECT product_id, quantity, 'OUT' AS type, outbound_time AS op_time FROM outbound_record ) r ON p.product_id = r.product_id AND YEAR(r.op_time) = p_year AND MONTH(r.op_time) = p_month GROUP BY p.product_id, p.product_code, p.product_name ORDER BY p.product_code; END // DELIMITER ;逻辑说明:用UNION ALL把入库和出库流水合并成一张临时表,再用CASE WHEN分别求和。LEFT JOIN保证没有流水的商品也显示,结存为 0。YEAR和MONTH函数在流水量大的时候会走全表扫描,如果数据超过几十万行,建议在inbound_time和outbound_time上建索引,或者改成按时间范围查询。
参数说明:p_year和p_month由 C# 传入,调用前在业务层校验月份在 1 到 12 之间。存储过程创建后可以用CALL sp_monthly_summary(2025, 1);测试。
5.2 C# 调用存储过程并绑定到报表界面
public DataTable GetMonthlySummary(int year, int month) { using (var conn = MySqlHelper.GetConnection()) using (var cmd = new MySqlCommand("sp_monthly_summary", conn)) { cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@p_year", year); cmd.Parameters.AddWithValue("@p_month", month); using (var adapter = new MySqlDataAdapter(cmd)) { var dt = new DataTable(); adapter.Fill(dt); return dt; } } }逻辑说明:CommandType.StoredProcedure告诉驱动这是存储过程,参数名要和存储过程定义的一致。返回的DataTable直接绑到报表的DataGridView或者导出 Excel 都行。导出 Excel 可以用ClosedXML或者NPOI,这里不展开。
参数说明:year和month从界面上的DateTimePicker取,注意DateTimePicker的CustomFormat设成yyyy-MM更直观。
5.3 一个我踩过的坑:存储过程参数名大小写
MySQL 存储过程的参数名在 Windows 上不区分大小写,但在 Linux 上区分。如果开发用 Windows、部署到 Linux 服务器,@p_year和@P_Year会被当成两个参数,调用时报Incorrect number of arguments。我的习惯是全部用小写加下划线,C# 里也保持一致。这个坑不常遇到,但一旦遇到能查半天。
5.4 验证整套系统是否跑通的最小清单
部署到新机器后,按这个顺序验证:第一步,mysql -u root -p -e "SHOW DATABASES;"确认warehouse_db存在;第二步,SELECT COUNT(*) FROM product;确认基础数据导入;第三步,在 C# 里点一次入库,然后SELECT * FROM inbound_record ORDER BY inbound_time DESC LIMIT 1;看流水;第四步,SELECT * FROM inventory WHERE product_id = 刚入库的商品;看库存是否增加;第五步,点一次出库,重复第三步和第四步看扣减。五步都过,系统基本可用。
我自己的习惯是每次改完数据访问层代码,先把这五步跑一遍再提交,比写单元测试快,也比事后拍脑袋找 bug 强。希望帮到你。
本文还有配套的精品资源,点击获取