ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

WinForms+SQL Server外卖系统开发:订单、库存与事务设计

WinForms+SQL Server外卖系统开发:订单、库存与事务设计 简介这是一份基于 WinForm 与 SQL Server 的外卖系统完整项目面向学习 C# 桌面开发与数据库设计的高校学生、课程设计者或初级开发者。项目按角色拆分为用户端、商家端、骑手端和管理员四个子系统用户端实现跨店铺加购、结算下单、订单管理、钱包充值及个人信息维护商家端包含商品上下架、库存与订单处理、骑手派单骑手端支持订单查询与接单派送管理员可统一管理用户、商家、骑手及系统配置角色权限清晰适合作为课设或毕设的完整参考。压缩包共 392 个文件以 C# 源码cs、窗体资源resx/resources、界面截图jpg/png为主另含 SQL 数据库脚本与 SQLite 数据文件便于在 SQL Server 中还原运行环境资源整体约 17.63MB解压后项目结构完整附带可执行文件与配置文件基本能做到拿到即用。目前已有 556 人学习下载若环境配置遇到问题也可联系作者协助安装适合希望快速上手外卖类桌面项目的读者对照实践。1. 为什么“winformsqlserver外卖系统”到现在还有店在用“winformsqlserver外卖系统”听上去已经有些年头但在快餐店、奶茶店和外卖档口的后厨电脑上它仍然是比网页端更让人安心的选择。餐厅收银机配置不高浏览器挂几天就卡桌面窗体独占内存点单键按下就有反应SQL Server 把订单、菜品和库存留在本地断网时门店还能继续接单网络恢复再同步。这个方案解决的是中小商家最实际的问题电话订餐、外卖平台下单、门店自取都进同一套订单流日终按状态统计不再靠 Excel 手工对账。适合手里只有几台 Windows 收银机、不想被订阅制 SaaS 绑定的技术团队或个体店。2. 先定表结构再写界面订单、菜品与库存的四表核心设计做这个系统我从来不是先拖控件而是先把表结构定下来。表结构一旦上线改字段比改界面疼得多尤其外卖订单涉及历史数据字段少了后面补全是血泪经验。下面这套四表模型是我做过几个餐厅项目后保留的最小可用方案。2.1 四张核心表和字段为什么订单明细要冗余一份菜名外卖系统的核心数据流是“菜单-购物车-订单-订单明细”四张表能覆盖单店场景下的接单、出餐、日结对账。先按这个脚本建表CREATE TABLE dish_category ( category_id INT IDENTITY(1,1) PRIMARY KEY, category_name NVARCHAR(50) NOT NULL ); CREATE TABLE dishes ( dish_id INT IDENTITY(1,1) PRIMARY KEY, dish_name NVARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL, stock_qty INT NOT NULL DEFAULT 0, category_id INT NOT NULL REFERENCES dish_category(category_id), status TINYINT NOT NULL DEFAULT 1, -- 1上架 0下架 created_at DATETIME2 NOT NULL DEFAULT SYSDATETIME() ); CREATE TABLE orders ( order_id BIGINT IDENTITY(1,1) PRIMARY KEY, order_no NVARCHAR(32) NOT NULL UNIQUE, platform_no NVARCHAR(50) NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, customer_name NVARCHAR(50) NULL, phone NVARCHAR(20) NULL, address NVARCHAR(200) NULL, created_at DATETIME2 NOT NULL DEFAULT SYSDATETIME() ); CREATE TABLE order_items ( id BIGINT IDENTITY(1,1) PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(order_id), dish_id INT NOT NULL, dish_name NVARCHAR(50) NOT NULL, unit_price DECIMAL(10,2) NOT NULL, qty INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL );orders 与 order_items 是一对多一个订单对应多行菜品。这里最关键的是 order_items 里同时存了 dish_id 和 dish_name。dish_id 用来后续跟菜品表关联统计销量dish_name 是下单那一刻的快照菜品后来改名、下架甚至删除历史订单打印出来还是原来的名字。这种冗余不是多余是订单系统的常识。dishes 表里的 stock_qty 是简单的库存数量适合炸鸡、奶茶这类按份售卖的品类。如果要做口味、规格和套餐组合可以再拆一个 sku 表但最小方案里先不引入。status 字段控制上下架查询菜品时永远带着 WHERE status 1避免把已下架菜显示给收银员。订单状态我先用 TINYINT 编号0 待处理、1 制作中、2 配送中、3 已完成、4 已取消具体含义放到程序里的枚举或常量类数据库不直接存中文状态避免前端显示文案变化时还要改库。平台单号 platform_no 建议保留。外卖平台把订单推到门店后门店自己的系统也要生成 order_no两张单号要同时存在。打印小票、用户追问订单时用 order_no 在店内查跟平台客服核对时用 platform_no。这个字段开始不做后面接平台时只能靠 order_items 备注硬凑很麻烦。2.2 金额一律用 decimal(10,2)别让浮点数吃掉一分钱金额字段是外卖系统里最容易埋雷的地方。很多人写开发环境时用 float 或 double 图省事跑几周后发现日结对不上账。原因很经典0.1 0.2 在 double 里是 0.30000000000000004单个订单看不出来一天几百单累加就差出几毛钱。SQL Server 的 float 是 8 字节近似存储而 decimal 是精确数值。外卖订单涉及单价、数量、打包费、配送费、折扣任何一步用浮点最后都可能差一分。我的统一规范是数据库里金额字段全部 DECIMAL(10,2)C# 里全部用 decimal禁止用 double 承载价格。举个例子decimal unitPrice 12.5m; decimal qty 3m; decimal subtotal unitPrice * qty; // 37.50 decimal discount subtotal * 0.88m; // 33.00 decimal final Math.Round(discount, 2, MidpointRounding.AwayFromZero);C# 里带 m 后缀的数字字面量才是 decimal 类型。subtotal * 0.88m得到 33.00是因为 decimal 乘法保留精度如果写成subtotal * 0.88编译器会认为是 double运算后隐式转回 decimal 时已经带了误差。折扣计算之后必须 Round 到两位并且选 AwayFromZero避免 2.005 这类值被银行家舍入成 2.00。SQL Server 端也一样字段类型是 decimalSUM 出来的还是 decimal不需要额外转换。如果订单总额可能超过百万比如连锁店做月报DECIMAL(18,2) 更合适单店订单表 DECIMAL(10,2) 已经能存 99999999.99实际很难打满。关键是把精度选择从一开始定下来后面不要再动。2.3 数据访问选型ADO.NET、Dapper 与 EF 的取舍WinForms 项目的数据访问层没有标准答案常见做法是三种纯 ADO.NET、Dapper、EF Core。老项目跑在 .NET Framework 4.x 和 SQL Server 2008R2 上时我会直接用 SqlConnection 和 SqlCommand依赖最少部署最简单新项目允许 NuGet 的话我一般加 Dapper它不引入实体跟踪SQL 还是自己写但省掉了 DataReader 到对象的样板代码。EF Core 在 Web 项目里很顺手在 WinForms 里做外卖系统却容易过度设计DbContext 的变更追踪稍不注意就把查询出来的旧数据覆盖回数据库排错成本比省下来的时间高。这里给一个最简 Dapper 用法查询端用它明显舒服using Dapper; using var conn Db.Open(); var dishes conn.QueryDish( SELECT dish_id, dish_name, price FROM dishes WHERE status status, new { status 1 }).ToList();Query 方法会自己打开/关闭连接吗不会它基于传入的已打开连接执行执行完不关闭所以外层用 using 包住 Db.Open() 是必要的。参数对象 new { status 1 } 是匿名类型Dapper 会把属性名和 SQL 参数名对应不用手写 SqlParameter。但要注意Dapper 对复杂多结果集和 bulk copy 支持一般那些场景回到 SqlCommand 或 SqlBulkCopy。如果团队里有人坚持 EF也拦不住但外卖核心写操作建议还是走原生 SQL 或存储过程主要是事务边界要完全握在自己手里。WinForms 客户端直连 SQL Server 的方式对局域网单店没问题连锁店多门店数据同步后面再考虑读写分离或汇总库。3. 用 WinForms 把下单流程跑通DataGridView、购物车与订单生成表结构定完下一步是把下单主流程跑通。WinForms 做外卖系统的经典组合是 DataGridView 显示菜单/订单 TextBox/ComboBox 做条件筛选 Button 触发动作。这一章不讨论美化和自定义控件先把流程走通界面再丑都能用逻辑漏了才是事故。3.1 连接串和 SqlConnection 工厂放在 app.config 比写死在代码里省心连接串写死在代码里是新手最常见的坑。换库、改密码、从开发机移到门店机每次都要重新编译。把连接串放到 app.config改一行配置就能换环境也能让 Dapper 和 SqlConnection 用同一份。最小配置是这样connectionStrings add nameTakeawayDB connectionStringServer.;DatabaseTakeawayDB;User Idsa;PasswordyourStrongPassword;MultipleActiveResultSetsTrue;TrustServerCertificateTrue; providerNameSystem.Data.SqlClient / /connectionStringsProgram.cs 入口处读取并赋给全局 Db 类var connStr ConfigurationManager.ConnectionStrings[TakeawayDB].ConnectionString; Db.ConnString connStr;连接串里几个参数按场景调Server. 表示本机默认实例门店另一台机器访问时写成 Server192.168.1.10如果装的是 SQL Server Express写 Server.\SQLEXPRESS。User Idsa 只建议开发环境用生产环境给应用建一个专门登录名权限只给目标库的读写权限别拿 sa 跑业务。MultipleActiveResultSetsTrue 的意思是同一个连接上可以同时存在多个活动结果集WinForms 里偶尔会在一个 DataReader 没关闭时又执行另一条命令打开它少报错。TrustServerCertificateTrue 解决本地开发时 SQL Server 自签名证书不受信任导致的连接报错正式环境最好让 IT 把证书配好或者根据内网安全要求关闭加密。Db.Open 工厂方法可以统一控制打开动作public static SqlConnection Open() { var conn new SqlConnection(ConnString); conn.Open(); return conn; }所有业务方法都从 Db.Open() 拿连接出问题时只改一个地方。ConnectionTimeout 不建议在连接串里写得太大内网系统 5 秒足够默认 15 秒对 WinForms UI 来说等待感太明显。3.2 菜品列表点击加购DataGridView 行事件和购物车集合点菜界面我习惯上面放一个 ComboBox 分类中间 DataGridView 显示当前分类菜品下面放数量输入框和“加入购物车”按钮。加载菜品用 SqlDataAdapter 填充 DataTableprivate void LoadDishes(int categoryId) { using var conn Db.Open(); var sql SELECT dish_id AS 编号, dish_name AS 菜品名, price AS 单价, stock_qty AS 库存 FROM dishes WHERE status 1 AND (category_id cat OR cat 0) ORDER BY dish_id;; var table new DataTable(); using var da new SqlDataAdapter(sql, conn); da.SelectCommand.Parameters.AddWithValue(cat, categoryId); da.Fill(table); dgvDishes.DataSource table; }SqlDataAdapter 会自动打开和关闭连接所以这里不需要手动 Open。SELECT 里取中文别名是为了让 DataGridView 直接显示中文列头但代码里取单元格值时必须用列名“编号”而不是数据库列名 dish_id。这个看起来是小事后面做双击行加入购物车时经常有人用错列名而导致 null 引用。如果你的项目还在 .NET Framework 4.7.2 以下C# 的 using var 语法可能不支持改成 using (var conn Db.Open()) 即可。DataGridView 建议设置 ReadOnlytrue、SelectionModeFullRowSelect、MultiSelectfalse。这样鼠标点哪行就整行选中不会误改单元格内容。加入购物车用 CurrentRow 取值private BindingListCartItem _cart new(); private void btnAdd_Click(object sender, EventArgs e) { if (dgvDishes.CurrentRow null) return; var row dgvDishes.CurrentRow; var id (int)row.Cells[编号].Value; var name row.Cells[菜品名].Value.ToString(); var price (decimal)row.Cells[单价].Value; var qty numericUpDownQty.Value; if (id 0 || qty 0) return; var existing _cart.FirstOrDefault(x x.DishId id); if (existing ! null) { existing.Qty qty; } else { _cart.Add(new CartItem { DishId id, DishName name, UnitPrice price, Qty qty }); } }CartItem 是带属性通知的普通类字段和 order_items 表基本一一对应。库存在这里不查因为用户加入购物车和最后下单之间可能隔着几分钟到时候库存可能已经变了放到提交订单时在事务里统一判断。如果用户点击列头排序CurrentRow 的单元格值不会错但行索引会变所以取值始终用 Cells[列名] 而不是 e.RowIndex 去 DataSource 里硬取。3.3 提交订单的入口方法校验、生成订单号、决定是否开事务下单按钮的逻辑顺序是先校验购物车再生成订单号最后调用数据层 CreateOrder。订单号不能在界面层拼好就算了我通常会把它放到业务对象里方便打印小票前先展示给收银员确认private string BuildOrderNo() { return DateTime.Now.ToString(yyyyMMddHHmmss) new Random().Next(100, 999).ToString(000); }单机场景用时间加三位随机数基本够用但多台收银机同时点单时同一秒撞号概率不是零。稳妥做法是在订单号里带门店编号比如SH00120250607120001345或者直接用数据库 sequence。接了外卖平台后order_no 可以继续用本地规则platform_no 拿去和平台对账两边不冲突。提交入口的完整长这样private void btnSubmitOrder_Click(object sender, EventArgs e) { if (!ValidateOrder()) return; var order new OrderDto { OrderNo BuildOrderNo(), PlatformNo txtPlatformNo.Text.Trim(), TotalAmount _cart.Sum(x x.Subtotal), CustomerName txtCustomerName.Text.Trim(), Phone txtPhone.Text.Trim(), Address txtAddress.Text.Trim() }; try { var orderId orderService.CreateOrder(order, _cart.ToList()); _cart.Clear(); MessageBox.Show($下单成功订单号{order.OrderNo}); RefreshOrderList(); } catch (Exception ex) { MessageBox.Show(ex.Message); } }ValidateOrder 里除了检查购物车为空还要把已下架菜品拎出来库存是否真够交给事务里的条件 UPDATE 判断不要在 UI 层先 SELECT 再下单因为那个值在点按钮那一刻就已经可能过期。界面层不直接碰 SqlTransaction这样可以保证换数据库访问方式时界面不用动。4. 订单如何安全落库参数化 SQL、事务与并发扣库存外卖系统的成败不在界面做得好看而在订单落库是不是可靠。前面三层有点击加购、购物车数量修改最后所有东西集中在一次 Insert 和 Update 里。这一章是核心写不严谨上线第一周就会出现丢单、超卖、对不上账。4.1 参数化 SQL 是外卖系统数据库操作的最低要求我刚入行时图省事写过这种代码var sql INSERT INTO orders(order_no,total_amount) VALUES( orderNo , total );当时觉得反斜杠引号加转义就够了直到一次订单号里带着英文单引号整条 SQL 语法错程序直接崩。更危险的是 total 如果来自界面输入被拼成1;DELETE FROM orders;--整张订单表都能被清掉。无论系统是内网还是外网都不能拼 SQL。参数化的写法看起来多几行但把类型和转义交给 ADO.NET 处理单引号、中文、日期格式都不是问题。SQL Server 收到的参数值永远是值不是可执行代码。后面所有增删改都走参数统一这一条规矩。值可能为空的字段用 DBNull.Value 显式传不要传空字符串否则会破坏字段语义。4.2 一个 CreateOrder 方法把主表、明细表和扣库存串在一起推荐的做法是把订单主表、明细表、库存扣减放在同一个数据库事务里。任何一步失败订单整体回滚不会出现主表有单、明细缺失或库存已经扣了订单没建成的脏状态。完整方法如下public int CreateOrder(OrderDto order, ListCartItem items) { const string insertOrder INSERT INTO orders(order_no, platform_no, total_amount, status, customer_name, phone, address, created_at) VALUES(orderNo, platformNo, totalAmount, status, customerName, phone, address, SYSDATETIME()); SELECT CAST(SCOPE_IDENTITY() AS bigint);; const string insertItem INSERT INTO order_items(order_id, dish_id, dish_name, unit_price, qty, subtotal) VALUES(orderId, dishId, dishName, unitPrice, qty, subtotal);; const string reduceStock UPDATE dishes SET stock_qty stock_qty - qty WHERE dish_id dishId AND stock_qty qty;; using var conn Db.Open(); using var tx conn.BeginTransaction(); try { long orderId; using (var cmd new SqlCommand(insertOrder, conn, tx)) { cmd.Parameters.AddWithValue(orderNo, order.OrderNo); cmd.Parameters.AddWithValue(platformNo, (object)order.PlatformNo ?? DBNull.Value); cmd.Parameters.AddWithValue(totalAmount, order.TotalAmount); cmd.Parameters.AddWithValue(status, (byte)OrderStatus.Created); cmd.Parameters.AddWithValue(customerName, (object)order.CustomerName ?? DBNull.Value); cmd.Parameters.AddWithValue(phone, (object)order.Phone ?? DBNull.Value); cmd.Parameters.AddWithValue(address, (object)order.Address ?? DBNull.Value); orderId (long)cmd.ExecuteScalar(); } foreach (var item in items) { using (var cmd new SqlCommand(insertItem, conn, tx)) { cmd.Parameters.AddWithValue(orderId, orderId); cmd.Parameters.AddWithValue(dishId, item.DishId); cmd.Parameters.AddWithValue(dishName, item.DishName); cmd.Parameters.AddWithValue(unitPrice, item.UnitPrice); cmd.Parameters.AddWithValue(qty, item.Qty); cmd.Parameters.AddWithValue(subtotal, item.Subtotal); cmd.ExecuteNonQuery(); } using (var cmd new SqlCommand(reduceStock, conn, tx)) { cmd.Parameters.AddWithValue(dishId, item.DishId); cmd.Parameters.AddWithValue(qty, item.Qty); int affected cmd.ExecuteNonQuery(); if (affected 0) throw new Exception(库存不足 item.DishName); } } tx.Commit(); return (int)orderId; } catch { tx.Rollback(); throw; } }有几个细节要讲清楚。BeginTransaction 之后所有 SqlCommand 的构造函数第二个参数必须传 tx否则命令会运行在事务外报错“The transaction is either not associated with this connection or has already been completed”。insertOrder 用 ExecuteScalar 是因为 SQL 里 SELECT SCOPE_IDENTITY() 会返回新生成的 order_id避免了 Insert 后再查一遍。SCOPE_IDENTITY 比 IDENTITY 安全前者只取当前会话当前作用域生成的标识列后者可能被触发器里的其他插入覆盖。扣库存的 UPDATE 是三个动作里最关键的一步它把“判断库存是否够”和“扣减库存”合在一条语句里数据库行锁会保证同一时间只有一个事务在改这一行。affected 0 表示没有行被更新通常是库存不够直接抛出异常让事务回滚。这样超卖很难发生。注意 qty 必须是正数调用层在 UI 里已经校验数据层最好再 guard 一次防止服务端被绕过。4.3 并发扣库存UPDATE ... WHERE 比先 SELECT 再 UPDATE 靠谱有人习惯先 SELECT stock_qty判断大于 0 再 UPDATE这在单用户系统里能跑多台收银机一上线就超卖。原因很简单两个事务同时 SELECT 到库存还剩 1然后都执行 UPDATE两个订单都成功。改成条件 UPDATE 后第二个事务的 UPDATE 会因为 stock_qty qty 不成立而影响 0 行被判定为库存不足。如果觉得普通 UPDATE 的锁粒度不够可以在 UPDATE 前显式加行锁UPDATE dishes WITH (UPDLOCK) SET stock_qty stock_qty - qty WHERE dish_id dishId AND stock_qty qty;UPDLOCK 会在事务内锁定该行直到提交避免两个事务在条件判断阶段都拿到相同快照。日常单店外卖系统普通条件 UPDATE 已经够用库存准确性要求很高时再用 UPDLOCK因为锁等待会让下单接口稍微变慢。还要注意退菜/取消订单的库存回补。取消订单不能只改状态为已取消必须把订单明细里的数量加回 dishes.stock_qty。回补操作同样建议放事务里不然取消成功但库存没恢复过几天就会发现畅销菜莫名缺货。这个场景和下单一样条件 UPDATE 改成SET stock_qty stock_qty qty不要求库存上限就省略条件。5. 外卖系统避坑与常见问题排查从连接失败到金额错乱的 5 类问题开发阶段跑通不是结束上线后运维才是真正考验。这一章是我把几个项目里反复出现的坑集中列出来每一条都按照“现象、原因、解决”的方式记录遇到同类的直接把检查顺序抄走。5.1 本机连不上 SQL Server先查服务、TCP/IP 和“句柄无效”错误现象SqlConnection.Open() 报“建立与服务器的连接时出错”或者重装 SQL Server 时提示“句柄无效。异常来自 HRESULT: 0x80070006”。这种报错在安装阶段和连接阶段都出现过。原因SQL Server 服务没启动是最常见的其次是 TCP/IP 协议在 SQL Server 配置管理器里被禁用SqlClient 默认通过 TCP 1433 端口连接协议没启用就连不上。安装阶段碰到句柄无效多数是旧实例没卸载干净残留服务和安装目录互相冲突。如果改过 sa 密码连接串里的密码还是旧的也会报登录失败错误信息长得像连接问题。解决第一步打开 SQL Server 配置管理器确认“SQL Server (MSSQLSERVER)”服务是“正在运行”右键重启一次。第二步在“SQL Server 网络配置”里启用 TCP/IP再进入属性把 IPAll 的 TCP 端口设为 1433。第三步用 SSMS 先试 Windows 身份登录能登录说明库本身没问题再检查应用连接串。安装阶段句柄无效就用 Windows 的“程序和功能”卸载 SQL Server手动删除安装目录再以管理员身份重装。注意装完 SQL Server 2022 后默认可能没开 TCP/IP很多初学项目翻车都翻在这一步。5.2 字符串转数字翻车TextBox 里的价格不能直接 Convert 去转现象界面里输入“12.5”点保存程序崩溃提示“输入字符串的格式不正确”或者 SQL Server 执行时报“将 varchar 转换为数据类型 int 时出错”。原因文本框的值本质是字符串里面可能有数字带小数点、前后空格、甚至是“12.5元”这种单位。用 Convert.ToInt32 只能解析整数看到小数直接抛异常。SQL 端如果拿一个 nvarchar 字段跟 int 字段比较隐式转换规则会把两边都转成 int遇到无法转的内容就报错。解决C# 侧统一用 TryParse不要用 Parseif (!decimal.TryParse(txtPrice.Text.Trim(), out var price) || price 0) { MessageBox.Show(价格必须是正数); return; }SQL 侧对不可信的外部输入用 TRY_CAST 而不是 CASTSELECT TRY_CAST(N12.5 AS decimal(10,2)); -- 返回 12.50 SELECT TRY_CAST(N12a AS decimal(10,2)); -- 返回 NULL不要用 ISNUMERIC 做前置判断它对科学计数法返回 1但 CAST(1e2 AS int) 仍然报错。更保险的是 TRY_CAST NULL 说明不能转换在 WHERE 里过滤掉。这个坑在导入平台订单数据时尤其高频Excel 里某一列混入中文备注整批导入失败是常态。5.3 订单明明保存了列表却看不到事务未提交与数据源未刷新现象新增订单提示“保存成功”但订单列表还是旧数据重启程序后订单才出现或者一直不出现。原因第一种是事务里执行了 Insert但没有调 Commit()using 块退出时连接关闭事务自动回滚对外表现就是“保存成功但没有数据”。第二种是 DataGridView 绑定的 DataTable 是内存里的旧对象新增操作没有触发重新查询。还有更隐蔽的场景用 DataAdapter.Update 时没有刷新主键同一行被 Insert 了两次平台侧会看到重复单。解决所有写操作统一走数据层事务的 Commit 放在 try 块最后Rollback 放在 catch写完后在 UI 层重新执行一次 LoadOrders 填充列表。如果列表只在新窗口显示要确认窗口每次打开都重新查询而不是复用第一次打开的 DataTable。用 SqlDataAdapter 做增删改时给 DataSet 配 SqlCommandBuilder 自动生成 Update 命令但前提是 SELECT 必须包含主键列否则生成主键冲突报“数据无效”。再不行就放弃 DataAdapter.Update改用参数化 SqlCommand至少出错能定位到具体 SQL。5.4 金额对不上的玄学浮点计算和四舍五入时机现象同样一单奶茶收银机上应收 13.10后台统计出来是 13.09日报里各项明细加起来和总额差一分钱。这类问题往往不是偶发各自重算一遍就对上了。原因金额字段用了 float/double或者代码里用 double 计算后转 decimal另一个原因是折扣和打包费的四舍五入时机不统一。有人先算明细小计后四舍五入有人先算总额再四舍五入结果自然不一样。SQL Server 端如果字段是 decimalSUM 不会有问题但程序里查询结果映射到 double 后再读出来精度已经被破坏。解决按第 2.2 的标准统一 decimal所有金额计算先在业务层算完只把最终两位小数传给数据库。折扣场景这样处理每个明细的折后价单独 Round 到两位再累加得到总价不要先加总再打折扣也不要反过来。每日对账不平时写一条 SQL 把订单表总金额和订单明细累加金额对比定位差异发生在哪一批订单再检查那批订单的代码路径。这活看着像玄学其实就是精度缺失。SELECT o.order_id, o.total_amount, SUM(oi.subtotal) AS item_total FROM orders o JOIN order_items oi ON oi.order_id o.order_id GROUP BY o.order_id, o.total_amount HAVING o.total_amount SUM(oi.subtotal);这条查询能快速找出主表总价和明细累计不一致的订单是金额类问题最强的定位工具。5.5 事务日志暴涨导致磁盘满查看日志和恢复正常状态的步骤现象C 盘空间从 40GB 掉到 0SQL Server 报“数据库‘TakeawayDB’的事务日志已满”系统无法写入新订单收银机全部卡住。原因数据库恢复模式是 FULL但没有配置日志备份导致事务日志文件一直增长或者某次大事务一次性导入了上万条订单日志瞬间撑满。外卖系统日单量不大但批量导入平台订单时很容易触发。解决先看日志占用情况DBCC SQLPERF(LOGSPACE);再在当前盘符足够的情况下把恢复模式改为 SIMPLE 并按需收缩日志文件ALTER DATABASE TakeawayDB SET RECOVERY SIMPLE; DBCC SHRINKFILE(TakeawayDB_log, 100);SIMPLE 模式会截断已提交事务的日志适合不需要时点恢复的单店系统。如果坚持 FULL就必须建维护计划每天至少做一次日志备份BACKUP LOG TakeawayDB TO DISK ND:\Backup\TakeawayDB_log.bak WITH INIT;不要把 SHRINKFILE 当日常操作它会造成文件碎片。另外注意 SqlBulkCopy 大批量导单时单批事务尽量控制在 1000 行以内减少日志瞬间压力。6. 上线后的自动对账与批量导单用 SQL Server Agent 和 SqlBulkCopy 省下大量手工活系统稳定运行后新的工作量来自两个地方每天要出一张准确的营业报表外卖平台导出的订单数据要进本地库。这两件事手工做很烦我一般让数据库和代码把活干了。6.1 每天凌晨自动生成营业汇总表单店日结的统计口径其实很简单已完成订单的数量和金额按天汇总。但这种查询写进 WinForms 里让收银员下班前手动点“生成日报”很容易忘。更稳的做法是交给 SQL Server Agent 作业每天凌晨自动执行一个存储过程。CREATE PROC dbo.GenerateDailyReport BizDate date AS BEGIN SELECT CAST(created_at AS date) AS biz_date, COUNT(*) AS order_count, SUM(total_amount) AS total_amount FROM orders WHERE status 3 -- 已完成 AND CAST(created_at AS date) BizDate GROUP BY CAST(created_at AS date); END在 SQL Server Agent 里新建一个作业步骤里调用EXEC dbo.GenerateDailyReport BizDate DATEADD(day, -1, CAST(GETDATE() AS date))计划设为每天 00:05。生成的报表可以落到结果表也可以由 WinForms 客户端次日打开时读取。统计状态要在一开始就定好已取消订单不要混入总额退款单单独列字段否则对账还得二次加工。6.2 把外卖平台订单批量导入本地库SqlBulkCopy 的常见用法平台订单每天可能有几百条一条条 INSERT 会慢也不值得。SQL Server 专门有 SqlBulkCopy 做批量写入WinForms 里用起来不复杂using var bulk new SqlBulkCopy(Db.ConnString); bulk.DestinationTableName orders; bulk.ColumnMappings.Add(OrderNo, order_no); bulk.ColumnMappings.Add(TotalAmount, total_amount); bulk.ColumnMappings.Add(Status, status); bulk.BatchSize 500; bulk.BulkCopyTimeout 60; await bulk.WriteToServerAsync(dataTable);ColumnMappings 把内存 DataTable 的列和数据库列对应起来顺序、数量都不要求一致。导入前必须先按 order_no 去重否则主键冲突直接整个批次失败。如果订单明细也要导先导 orders 拿到自增的 order_id再导 order_items 并回填 order_id。SqlBulkCopy 默认不是一个原子事务写入途中失败会留下部分数据所以正式导单前我会先校验数据条数和金额合计数不一致就不导。按钮事件如果不方便写 async就改成同步 WriteToServer数据量几百条时界面卡一下也可以接受。我自己习惯是任何批量导入前先把平台文件转成 UTF-8 的 CSV 或 DataTable再用一条查询核对 order_no 是否已存在最后才交给 SqlBulkCopy。这个习惯救过我很多次有一次平台导出的订单号里混了不可见空格去重时没发现差点产生几百个重复订单校验合计金额时才发现。希望帮到你。本文还有配套的精品资源点击获取
返回列表