简介:一份基于C#与MySQL的WinForm商品销售与管理系统课程设计完整项目,面向计算机专业学生、初中级.NET开发者,可用于课程设计、毕业设计或小型商用系统二次开发。系统以用户表、商品表、订单表为数据核心,完整覆盖登录验证与在线权限判断、用户注册、商品增删改查、条件筛选、模糊查询与批量修改、收银台计价结算、订单浏览与时间过滤、用户资料与权限管理,以及商品库存补充等业务场景,同时对可能出现的异常进行了统一处理,界面经过精心美化,交互体验优于普通作业级项目。压缩包为zip格式,共127个文件,13.93MB,内部包含27个C#源码文件、11个资源文件及49个图片图标,另有项目解决方案与配置文件、SQL脚本可直接导入MySQL数据库,xmind思维导图帮助梳理模块设计,PDF与Word文档记录开发说明,还附带了可运行的exe文件便于快速查看实际效果。目前已有343人学习下载,适合需要完整可运行工程、数据库脚本与界面美化参考的开发者快速上手。
1. 门店打烊后最怕的账目对不上,这套系统就是在治这个病
门店每天营业结束,最怕的不是累,而是销售记录和库存对不上。手写单据时代对账靠人肉,Excel时代对账靠VLOOKUP,数据一多照样翻车。这就是为什么基于C#+MySQL的WinForm商品销售与管理系统在中小门店里一直有需求:商品档案、入库、销售、库存扣减、报表统计,全部收拢到一个桌面程序里完成。C#+MySQL+WinForm这个组合的优势很实在——DataGridView做表格界面效率高,MySQL免费且事务稳定,WinForm部署起来不用折腾Web服务器,一套方案从开发到交付的成本都压得住。
这篇笔记顺着可落地的方案往下拆:先设计数据库,再写WinForm界面和下单事务,然后处理查询报表与单据编号,把部署后最容易翻车的连接池、乱码、假死问题单独列出来,最后给一个把下单封装进存储过程的进阶写法。每个步骤都按“能跑起来”为标准,代码可以直接抄去改。适合两类人:一类是给中小门店做信息化的小型项目负责人,另一类是刚学完C#基础、想拿完整业务练手的开发新人。
2. 数据库设计与连接配置:为什么库存要单独一张表
2.1 表结构设计:八张表撑起最小可用系统
做这类管理系统,第一步不是急着画窗体,而是把表拆清楚。拆表的经验法则很简单:基础资料和业务单据分开。用户、商品是基础资料,入库、销售是业务单据,库存则单独作为“当前状态”存在。
这套系统的最小表结构可以收成八张表:
| 表名 | 职责 | 关键字段 |
|---|---|---|
| t_user | 操作员账号 | id, username, password, real_name |
| t_product | 商品档案 | product_code, product_name, category, sale_price, cost_price, is_on_sale |
| t_stock | 当前库存 | product_code, stock_qty, warning_line |
| t_stock_in | 入库单主表 | order_no, user_id, total_amount, in_time |
| t_stock_in_item | 入库明细 | order_no, product_code, qty, price |
| t_sale | 销售单主表 | order_no, user_id, total_amount, sale_time |
| t_sale_item | 销售明细 | order_no, product_code, qty, price |
| t_order_seq | 单据序号表 | seq_date, seq_value |
t_stock独立成表,而不是在t_product里加一个库存字段,这是我自己做过之后才想明白的。库存代表的是“当前状态”,销售明细是“流水事实”,二者分开之后,万一库存数据坏了,还能通过入库流水减销售流水重新算出来,等于给自己留了后悔药。另外也有扩展空间,以后要多仓库,在t_stock上加一个warehouse_id字段就行,t_product完全不用动。
2.2 建库建表SQL:字符集、索引和物理外键的三个决定
建库时字符集直接上utf8mb4,别再用utf8。MySQL的utf8是utf8mb3,存不了emoji,而且一些生僻字也会出问题。排序规则用utf8mb4_general_ci,兼容性比MySQL 8默认的utf8mb4_0900_ai_ci更稳,老版本驱动连接也不会报排序规则冲突。
CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE shop; CREATE TABLE t_product ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键,只做唯一标识,不参与业务', product_code VARCHAR(32) NOT NULL COMMENT '商品编码,单据和条码上可见', product_name VARCHAR(64) NOT NULL COMMENT '商品名称', category VARCHAR(32) DEFAULT '未分类' COMMENT '分类', sale_price DECIMAL(10,2) NOT NULL COMMENT '销售单价', cost_price DECIMAL(10,2) DEFAULT 0 COMMENT '成本价,用于毛利报表', is_on_sale TINYINT(1) DEFAULT 1 COMMENT '是否启用,0停用1启用', UNIQUE KEY uk_code (product_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品档案'; CREATE TABLE t_stock ( id INT AUTO_INCREMENT PRIMARY KEY, product_code VARCHAR(32) NOT NULL COMMENT '商品编码', stock_qty INT NOT NULL DEFAULT 0 COMMENT '当前库存', warning_line INT NOT NULL DEFAULT 10 COMMENT '库存预警线', UNIQUE KEY uk_code (product_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前库存'; CREATE TABLE t_sale ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '销售单号', user_id INT NOT NULL COMMENT '操作员ID', total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总额', sale_time DATETIME NOT NULL COMMENT '下单时间', UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售主表'; CREATE TABLE t_sale_item ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '销售单号', product_code VARCHAR(32) NOT NULL COMMENT '商品编码', qty INT NOT NULL COMMENT '数量', price DECIMAL(10,2) NOT NULL COMMENT '成交单价', KEY idx_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售明细';注意几个决定。第一,所有业务表都用InnoDB,事务和行锁都靠它。第二,物理外键我刻意没挂,只建普通索引。原因很直接:导入历史数据和删单的时候,物理外键会制造一堆麻烦;主从表之间的关联靠应用层用事务保证,这是WinForm进销存项目里的常见做法。第三,业务编码如product_code、order_no都建唯一键,这不仅是查询快,更是防止重复数据最后一道保险。
2.3 连接字符串封装:App.config里存什么、代码里怎么读
连接串别写死在代码里。换一台电脑部署就要重新编译,这种坑踩过一次就够了。把连接串放进App.config,程序启动时自动读取,换环境只改配置文件。
<configuration> <connectionStrings> <add name="MySqlConn" connectionString="Server=127.0.0.1;Port=3306;Database=shop;Uid=root;Pwd=your_password;CharSet=utf8mb4;SslMode=none;" providerName="MySql.Data.MySqlClient" /> </connectionStrings> </configuration>读取和取连接的代码我一般统一放到一个DbHelper静态类里,所有窗体共用这一处:
using System.Configuration; using MySql.Data.MySqlClient; public static class DbHelper { public static string ConnStr => ConfigurationManager.ConnectionStrings["MySqlConn"].ConnectionString; public static MySqlConnection GetConnection() { MySqlConnection conn = new MySqlConnection(ConnStr); conn.Open(); return conn; } }连接串里的参数值得逐个说。CharSet=utf8mb4是解决中文乱码的关键,和第2.2节建库时指定的字符集两头对齐,否则库是utf8mb4、连接是latin1,照样乱。SslMode=none是因为本地或内网部署的MySQL通常不开SSL,默认的Preferred模式在某些驱动版本下会直接报SSL连接错误。用的时候统一走using,让连接池自己管理释放:
using (MySqlConnection conn = DbHelper.GetConnection()) { // 查询或写库操作 }连接池负责复用连接,池里没释放的连接才会越积越多。很多“运行两小时就报连接池满”的问题,根源就是有一处没释放。
3. WinForm界面与下单逻辑:从DataGridView绑定到事务扣库存
3.1 主窗体布局:左菜单右表格是WinForm项目案例里的标准答案
WinForm做进销存,布局不需要花哨,最稳的结构是左菜单右内容。左侧放一组按钮,对应商品管理、入库、销售、库存、报表、退出;右侧用一个Panel放UserControl,点哪个菜单就切哪个模块。底部StatusStrip显示当前登录用户和库存预警数量,员工一眼能看到有没有缺货风险。
切模块的代码用一个函数控制,所有菜单按钮都调它:
private void ShowPanel(UserControl uc) { mainPanel.Controls.Clear(); uc.Dock = DockStyle.Fill; mainPanel.Controls.Add(uc); }每个业务模块单独做一个UserControl,比如ProductManageControl、SaleControl、StockReportControl,窗体之间互不掺和。这个习惯比界面好看重要得多。很多WinForm项目案例做到后面改不动,就是因为十几个功能全堆在Form1.cs里,一个按钮事件上千行。
提示:菜单按钮用FlatStyle配合BackColor改底色就能有不错的观感,WinForm界面美化不需要引入第三方皮肤,先把布局和数据流理清,再考虑颜色不迟。
3.2 商品列表加载与0/1显示为CheckBox
商品列表是后台管理系统最常用的界面,加载逻辑直接绑DataGridView。用DataTable存查询结果,比List 更省事,因为DataGridView绑定DataTable后,列名自动对应字段名。
string sql = @"SELECT p.product_code, p.product_name, p.category, p.sale_price, s.stock_qty, CASE WHEN p.is_on_sale = 1 THEN 1 ELSE 0 END AS is_on_sale FROM t_product p LEFT JOIN t_stock s ON p.product_code = s.product_code"; DataTable dt = new DataTable(); using (MySqlConnection conn = DbHelper.GetConnection()) using (MySqlDataAdapter da = new MySqlDataAdapter(sql, conn)) { da.Fill(dt); } dataGridView1.DataSource = dt;这里有个WinForm里特别常见的问题:is_on_sale字段在MySQL里是TINYINT(1),不同版本的MySQL驱动映射回来可能是bool也可能是int,直接绑定DataGridView有时候显示成复选框,有时候显示成0和1,行为像玄学。我不赌驱动行为,SQL里用CASE先转成0/1,再在DataGridView里手动加一列复选框列:
DataGridViewCheckBoxColumn checkCol = new DataGridViewCheckBoxColumn(); checkCol.DataPropertyName = "is_on_sale"; checkCol.HeaderText = "启用"; checkCol.TrueValue = 1; checkCol.FalseValue = 0; dataGridView1.Columns.Add(checkCol);DataPropertyName必须和数据源里的列名一致,TrueValue和FalseValue要跟SQL里转出来的值类型一致,int就是1和0,别写字符串"1"。这样不管驱动版本怎么变,界面都稳定显示复选框。
3.3 销售下单:锁库存、写单据、扣库存必须同一个事务
销售下单是整个系统的核心操作,涉及四件事:锁库存、校验库存、写销售单和明细、扣库存。这四步必须在一个数据库事务里,否则任何一个步骤失败都会留下脏数据。这里我给出一个可以直接抄的下单方法:
private void btnSubmit_Click(object sender, EventArgs e) { string orderNo = OrderNoGenerator.Next("SALE"); using (MySqlConnection conn = DbHelper.GetConnection()) { MySqlTransaction tx = conn.BeginTransaction(IsolationLevel.ReadCommitted); try { MySqlCommand cmd = new MySqlCommand(); cmd.Connection = conn; cmd.Transaction = tx; // 第一步:锁住库存行,防止并发超卖 cmd.CommandText = "SELECT stock_qty FROM t_stock WHERE product_code=@code FOR UPDATE"; cmd.Parameters.Clear(); cmd.Parameters.AddWithValue("@code", txtCode.Text.Trim()); object result = cmd.ExecuteScalar(); if (result == null) { MessageBox.Show("商品不存在或未建档"); tx.Rollback(); return; } int stockQty = Convert.ToInt32(result); int buyQty = (int)numQty.Value; if (stockQty < buyQty) { MessageBox.Show($"库存不足,当前剩余 {stockQty} 件"); tx.Rollback(); return; } // 第二步:写销售主表 decimal total = numPrice.Value * buyQty; cmd.CommandText = @"INSERT INTO t_sale(order_no, user_id, total_amount, sale_time) VALUES(@orderNo, @userId, @total, NOW())"; cmd.Parameters.Clear(); cmd.Parameters.AddWithValue("@orderNo", orderNo); cmd.Parameters.AddWithValue("@userId", CurrentUser.Id); cmd.Parameters.AddWithValue("@total", total); cmd.ExecuteNonQuery(); // 第三步:写销售明细 cmd.CommandText = @"INSERT INTO t_sale_item(order_no, product_code, qty, price) VALUES(@orderNo, @code, @qty, @price)"; cmd.Parameters.Clear(); cmd.Parameters.AddWithValue("@orderNo", orderNo); cmd.Parameters.AddWithValue("@code", txtCode.Text.Trim()); cmd.Parameters.AddWithValue("@qty", buyQty); cmd.Parameters.AddWithValue("@price", numPrice.Value); cmd.ExecuteNonQuery(); // 第四步:扣减库存 cmd.CommandText = "UPDATE t_stock SET stock_qty = stock_qty - @qty WHERE product_code = @code"; cmd.Parameters.Clear(); cmd.Parameters.AddWithValue("@qty", buyQty); cmd.Parameters.AddWithValue("@code", txtCode.Text.Trim()); cmd.ExecuteNonQuery(); tx.Commit(); MessageBox.Show($"下单成功,单号:{orderNo}"); RefreshStockAlert(); } catch (Exception ex) { tx.Rollback(); MessageBox.Show("下单失败:" + ex.Message); } } }这段代码里有几个细节值得说道。FOR UPDATE是InnoDB的行锁,两个销售窗口同时卖同一个商品时,后拿到锁的那个会等前面提交,从根上杜绝超卖。事务隔离级别用ReadCommitted,这是MySQL默认级别,别为了“安全”改成RepeatableRead,锁范围会扩大,高并发下容易死锁。每条语句前都做一次Parameters.Clear(),防止上一个语句的参数污染到下一个语句,这个习惯能排查掉很多“参数对不上”的诡异报错。
3.4 库存预警:低于安全线的两种提醒方式
库存预警不复杂,但很能体现系统是否用心。每个商品在t_stock里都有warning_line字段,默认10,可以单独调整。主窗体载入时和每次下单成功后,都查一次库存,低于预警线的商品数量显示在状态栏,列表里对应行标黄。
foreach (DataGridViewRow row in dataGridView1.Rows) { int stock = Convert.ToInt32(row.Cells["stock_qty"].Value); int line = Convert.ToInt32(row.Cells["warning_line"].Value); if (stock <= line) { row.DefaultCellStyle.BackColor = Color.LightYellow; } }这个方案足够应付中小门店。不建议在MySQL里做触发器去预警,WinForm是主动拉数据的模型,触发器把简单的事情变复杂。预警值放在库存表而不是商品表,是考虑到不同商品的补货周期不一样,饮料一天补一次,家电一周补一次,各自设各自的线。
4. 查询、报表与单据编号:参数化SQL和序列表的两个硬骨头
4.1 组合查询:动态WHERE的写法与AddWithValue的边界
商品管理页面通常有筛选条件:商品名模糊搜索、分类下拉、是否启用、库存区间。组合查询的常见写法是动态拼接WHERE,但参数必须用AddWithValue,不能直接拼字符串进SQL,否则一个单引号就能让查询炸掉,还可能被注入。
string sql = @"SELECT product_code, product_name, category, sale_price, stock_qty FROM v_product_list WHERE 1=1"; if (!string.IsNullOrEmpty(txtName.Text.Trim())) { sql += " AND product_name LIKE @name"; } if (cboCategory.SelectedIndex > 0) { sql += " AND category = @cat"; } using (MySqlConnection conn = DbHelper.GetConnection()) using (MySqlDataAdapter da = new MySqlDataAdapter(sql, conn)) { if (!string.IsNullOrEmpty(txtName.Text.Trim())) da.SelectCommand.Parameters.AddWithValue("@name", "%" + txtName.Text.Trim() + "%"); if (cboCategory.SelectedIndex > 0) da.SelectCommand.Parameters.AddWithValue("@cat", cboCategory.SelectedValue.ToString()); DataTable dt = new DataTable(); da.Fill(dt); dataGridView1.DataSource = dt; }WHERE 1=1不是性能问题,是拼串方便的惯用写法,MySQL会把它优化掉。AddWithValue有一个边界要记住:如果参数值是null,某些版本的MySql.Data驱动会报“参数类型无法推断”,所以传参前一定要做空值判断,别直接把DBNull.Value塞进去。像上面这样,条件满足才Add参数,SQL和参数一一对应,是最稳妥的。
4.2 日销售与毛利报表:GROUP BY + SUM的两种写法
报表是老板唯一关心的东西。按日统计销售额和单量,SQL用GROUP BY按天汇总:
SELECT DATE(sale_time) AS sale_date, COUNT(DISTINCT order_no) AS order_cnt, SUM(total_amount) AS sale_amount FROM t_sale WHERE sale_time >= @start AND sale_time < DATE_ADD(@end, INTERVAL 1 DAY) GROUP BY DATE(sale_time) ORDER BY sale_date DESC;日期边界用“大于等于开始日,小于结束日的下一天”,这样能完整包含结束日当天最后一秒的单子。C#里传参数时结束日要加一天:
cmd.Parameters.AddWithValue("@start", dtStart.Value.Date); cmd.Parameters.AddWithValue("@end", dtEnd.Value.Date.AddDays(1));毛利报表需要连商品表取出成本价:
SELECT DATE(s.sale_time) AS sale_date, SUM(si.qty * (si.price - p.cost_price)) AS gross_profit FROM t_sale s JOIN t_sale_item si ON s.order_no = si.order_no JOIN t_product p ON si.product_code = p.product_code GROUP BY DATE(s.sale_time) ORDER BY sale_date DESC;这里有个报表设计上的细节:如果商品成本价会变,历史毛利报表会被改价污染。所以正规做法是在销售明细里冗余一个cost_price字段,下单时把当时的成本价存进去。库存金额和毛利统计都从明细里取,商品表的cost_price只负责录单时带出默认值。小项目一开始可以不做,但老板一旦问“上个月毛利怎么变了”,这个字段的用处就体现出来了。
4.3 单据编号生成:日期+随机数为什么不好使
很多人图省事,单号用时间戳加随机数拼出来。这种方案在并发超过几十个单据时就有概率撞号。撞号不可怕,可怕的是t_sale.order_no上的唯一索引让第二次插入直接抛异常,客户正打着单呢突然报错。
正确的做法是加一张序列表。表结构我在第2.1节列过,取号逻辑用一条SQL搞定:
INSERT INTO t_order_seq(seq_date, seq_value) VALUES('20250601', 0) ON DUPLICATE KEY UPDATE seq_value = LAST_INSERT_ID(seq_value + 1);C#里拿号:
string today = DateTime.Now.ToString("yyyyMMdd"); using (MySqlConnection conn = DbHelper.GetConnection()) { MySqlCommand cmd = new MySqlCommand(@"INSERT INTO t_order_seq(seq_date, seq_value) VALUES(@today, 0) ON DUPLICATE KEY UPDATE seq_value = LAST_INSERT_ID(seq_value + 1); SELECT LAST_INSERT_ID();", conn); cmd.Parameters.AddWithValue("@today", today); int seq = Convert.ToInt32(cmd.ExecuteScalar()); return "SALE" + today + seq.ToString("D4"); }这个写法的核心在LAST_INSERT_ID(seq_value + 1)。ON DUPLICATE KEY UPDATE触发时,LAST_INSERT_ID返回的是更新后的seq_value,不用先SELECT再UPDATE跑两趟。并发时InnoDB的行锁保证同一天内取到的序号不重复,唯一索引也不用担心撞号。这是比“日期+随机数”稳一个量级的方案,值得直接从项目里抄走。
4.4 DataGridView显示优化:金额格式、日期格式与只读
查询结果展示时,几个格式化设置能让表格专业很多:
dataGridView1.ReadOnly = true; dataGridView1.RowHeadersVisible = false; dataGridView1.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill; dataGridView1.Columns["sale_price"].DefaultCellStyle.Format = "N2"; dataGridView1.Columns["sale_price"].DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight; dataGridView1.Columns["sale_time"].DefaultCellStyle.Format = "yyyy-MM-dd HH:mm:ss";ReadOnly要设成true,列表页本就只读,留着编辑会带来误操作。金额右对齐是财务习惯,日期格式按业务需要的精度来,精确到秒还是到分钟看老板要求。报表数据量大时再补一句:DataGridView的VirtualMode性能比普通模式好很多,但中小门店几千行数据远远到不了需要它的程度,别过早优化。
5. 部署后最容易翻车的5个问题:连接池、乱码、假死与超额库存
这套系统逻辑不复杂,但部署到客户机器上之后,翻车往往翻在环境差异上。下面5个问题是我见过最多的,每条都按现象、原因、解决的顺序写,照着排查能省一整天时间。
5.1 运行两小时后突然报错:连接池已满
现象:程序刚启动一切正常,用了一两个小时,点查询没反应,过一会儿弹“无法从连接池获取连接”或超时。
原因:代码里new了MySqlConnection,用完后没关闭。连接池默认上限是100,每次查询都泄漏一个连接,池满了自然取不到。最常见的是写查询方法时没用using,或用了try-catch后忘记在finally里Close。
解决:统一走DbHelper.GetConnection,并且所有调用都用using包住。排查时可以先把连接串临时加上Pooling=false,让每次连接真实创建,程序会立刻暴露出泄漏点,定位后把Pooling=false去掉,再用using修好所有连接。注意,一个连接只释放一次,using块结束后别再手动Dispose,双重复用反而会出问题。
5.2 MySQL里好好的中文,程序里查出来全是问号
现象:在Navicat或MySQL Workbench里看中文数据完全正常,WinForm里一查,商品名全变成“???”,写进去也一样。
原因:字符集两头没对齐。建库时用了默认字符集latin1,或者连接串里没指定CharSet,或驱动默认用了utf8mb3,都可能出现这种情况。
解决:先看服务器字符集和表字符集,再改连接串,三步对齐。
SHOW VARIABLES LIKE 'character_set_server'; SHOW CREATE TABLE t_product;建库和建表统一用utf8mb4,连接串加CharSet=utf8mb4,配置文件里的SslMode=node和CharSet=utf8mb4要同时存在。只改连接串不改库,还是乱;只改库不改连接串,照样乱,两个必须一起。老库可以通过ALTER TABLE把字符集转过来,但要注意先备份。这个坑的最大特点是:它只在客户机器上出现,开发机从来不复现,所以部署前一定要在全新的MySQL环境里跑一遍完整流程。
5.3 绑定了bool字段,DataGridView就是不显示复选框
现象:is_on_sale在C#模型里是bool,绑定List 到DataGridView后,列上显示“0/1”或“True/False”,就是不出现复选框。
原因:DataGridView对bool类型确实会自动生成复选框列,但MySQL驱动把TINYINT(1)读回来后,类型可能是sbyte或int,不是bool,自动判断失效。不同版本的MySql.Data映射行为不一样,这就是为什么有人换了一台机器,明明代码没动,显示却变了。
解决:SQL里用CASE统一把值转成0/1,列用DataGridViewCheckBoxColumn,TrueValue=1,FalseValue=0。如果旧代码改不了SQL,用CellFormatting事件兜底:
private void dgv_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (dataGridView1.Columns[e.ColumnIndex].Name == "is_on_sale" && e.Value != null) { e.Value = Convert.ToInt32(e.Value) == 1; } }注意,这个事件只在列类型是CheckBox时才对显示生效,所以要么用SQL转列,要么用格式化事件,别两套都上,否则值会被转换两次。
5.4 销售单录了,库存却没扣,月底盘点点名对账
现象:销售明细有记录,但t_stock表里库存数量没变,或者扣了两次。月底对账时才发现,库存永远对不上。
原因:下单代码没包事务,或者事务里有一条Command没有指定Transaction属性。写销售明细成功、扣库存失败,数据就停留在“有单无量”的状态。
解决:把第3.3节的下单方法原样抄走,所有写库命令挂在同一个MySqlTransaction上。自己验证也很简单:程序跑到扣库存那一步时,直接杀掉进程模拟断电,再查数据库,如果只多了销售单没扣库存,就说明事务没包住。另外给t_sale.order_no的唯一索引留好,重复提交单据时第二次插入会抛异常,等于系统自带防止重复下单的保险。这个防重的思路能救很多“我明明只点了一次按钮”的客户投诉。
5.5 窗口直接“未响应”:主线程里跑了几秒的SQL
现象:点击查询后整个窗体拖不动,标题栏变成“未响应”,过几秒恢复。数据量一大,甚至要等十几秒。
原因:DataAdapter.Fill或ExecuteReader执行在UI线程上,查询期间消息泵被阻塞,系统就判定窗体无响应。
解决:用async/await配合Task.Run,把耗时查询丢到线程池,UI线程继续干活。
private async void btnSearch_Click(object sender, EventArgs e) { btnSearch.Enabled = false; try { DataTable dt = await Task.Run(() => LoadData()); dataGridView1.DataSource = dt; } finally { btnSearch.Enabled = true; } }注意两个细节:按钮先禁用,防止用户在等待期间连点触发多次查询,否则每次点击都起一个Task,数据库被连环轰炸;await切换回UI线程后,DataGridView的DataSource赋值还是在主线程完成的,不会碰到跨线程访问控件的红线。本地数据库几百行数据感觉不到差别,但销售流水到十万行以上时,这个改动从“窗口假死”变成“丝滑查询”,体验完全不同。
6. 进阶:把下单封装进存储过程,用三表查询验证一致性
6.1 什么时候把业务下沉到SQL层
WinForm里写业务逻辑灵活,调试方便,但像“下单”这种核心操作,规则相对稳定,放在存储过程里收益更高:事务边界在数据库内部,C#端一个命令调完,不用自己管Rollback和Commit的细节;以后如果要加移动端或者Excel批量导入,业务规则不会被绕过。什么时候别用存储过程?业务规则频繁变,比如促销方案一个月改三次,那还是留在C#里改起来成本低。判断标准就一条:这个逻辑是不是长期不变的核心链路,是就下沉,不是就留在应用层。
6.2 存储过程怎么写
下单存储过程把库存校验、写主表、写明细、扣库存全部包在一个事务里,任何一步出错,整体回滚并把错误抛回给C#端:
DELIMITER $$ CREATE PROCEDURE sp_sale( IN p_order_no VARCHAR(32), IN p_user_id INT, IN p_product_code VARCHAR(32), IN p_qty INT, IN p_price DECIMAL(10,2) ) BEGIN DECLARE v_stock INT; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '下单失败,事务已回滚'; END; START TRANSACTION; SELECT stock_qty INTO v_stock FROM t_stock WHERE product_code = p_product_code FOR UPDATE; IF v_stock < p_qty THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足'; END IF; INSERT INTO t_sale(order_no, user_id, total_amount, sale_time) VALUES(p_order_no, p_user_id, p_qty * p_price, NOW()); INSERT INTO t_sale_item(order_no, product_code, qty, price) VALUES(p_order_no, p_product_code, p_qty, p_price); UPDATE t_stock SET stock_qty = stock_qty - p_qty WHERE product_code = p_product_code; COMMIT; END$$ DELIMITER ;EXIT HANDLER是MySQL存储过程的异常捕获,一旦执行中报错就回滚,并用SIGNAL把错误信息抛给客户端,C#端会收到带Message的异常。FOR UPDATE在存储过程里同样生效,并发超卖的防护没有丢。这个结构是C#高级编程里事务和存储过程结合的典型用法。
6.3 C#调用存储过程与三表验证
C#端调用很简单,CommandType设为StoredProcedure,参数按存储过程的入参传:
using (MySqlConnection conn = DbHelper.GetConnection()) using (MySqlCommand cmd = new MySqlCommand("sp_sale", conn)) { cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@p_order_no", orderNo); cmd.Parameters.AddWithValue("@p_user_id", CurrentUser.Id); cmd.Parameters.AddWithValue("@p_product_code", productCode); cmd.Parameters.AddWithValue("@p_qty", buyQty); cmd.Parameters.AddWithValue("@p_price", price); cmd.ExecuteNonQuery(); }调用完成后,用一条SQL同时查三张表验证数据一致性:
SELECT '销售主表' AS 来源, COUNT(*) AS 记录数 FROM t_sale WHERE order_no = 'SALE202506010001' UNION ALL SELECT '销售明细', COUNT(*) FROM t_sale_item WHERE order_no = 'SALE202506010001' UNION ALL SELECT '库存剩余', stock_qty FROM t_stock WHERE product_code = 'ITEM001';正确的结果是:主表记录数1,明细记录数1,库存剩余比下单前少了p_qty。如果主表和明细数量一致但库存没变,事务没包住;如果明细和库存都对但主表没有,主表插入失败被吞了。这个验证习惯我一直保留着,任何一次重构,先跑这条SQL再往下走。
做这类系统,界面朴素一点没关系,但数据流一定要严,订单和库存的每一步操作要么一起成功,要么一起回滚。我也曾贪图省事把判断库存的代码直接写在按钮事件里,后来加批量退单功能时改得心惊胆战。把下单逻辑收进存储过程之后,WinForm端动得越来越少,加促销只需要改入参,这算是我在这个项目上做过最值的一次调整。希望帮到你。
本文还有配套的精品资源,点击获取