ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ASP.NET餐饮管理系统源码实战:部署、排错与二次开发

ASP.NET餐饮管理系统源码实战:部署、排错与二次开发

简介:面向餐饮行业信息化开发学习者与中小餐厅管理人员的 ASP.NET 餐饮管理系统完整项目包,以 Web 形式实现菜单、订单、库存、会员、报表与后台权限等常见业务,可作为课程设计、毕业设计或企业原型参考。包内共约 2000 个文件、整体 46.58MB,涵盖 css/gif/js/png/jpg 等前端与图片素材,dll/pdb/exe 等程序与依赖组件,cs/aspx/master/ashx/asmx 等源码与接口文件,以及 config/xml/sln/csproj 等配置与工程文件,另含少量 scss、nupkg、ps1 等构建脚本,目录层次较完整,便于按表现层、业务逻辑层与数据访问层对照阅读。已有 949 人学习。读者可从中获得一套基于 SQL Server 2008 的三层架构实现范例,理解控件与数据绑定、AJAX 局部刷新、WebService 与 ashx 接口调用,以及 ADO.NET 或 Entity Framework 数据访问的落地写法,并借鉴订单状态流转、库存预警、会员积分与销售报表等模块的设计思路,用于二次开发与功能裁剪。

1. 从一家小馆子“后厨不出单”说起

去年帮朋友看一家做快餐的小馆子,前台点完菜,后厨得靠服务员跑一趟口头喊,一忙就漏单、重单;月底老板想知道卖了多少钱,只能翻收银机纸带一张张对。他的诉求很朴素:前台点餐、后厨自动出小票、月底能导出营业额。外面一套 SaaS 按年付费不便宜,断网还不能用,最后落到本地部署的 asp.net 餐饮管理系统上。这类源码包结构大同小异:ASP.NET WebForms 页面加 SQL Server,功能覆盖开台点餐、菜品管理、结账收银、会员和营业报表,扔在局域网一台 IIS 上就能跑起来。适合两类人:一类是给中小门店做本地化交付、需要按门店习惯改字段和小票模板的开发者;另一类是想拿一套能跑的业务系统练手 CRUD 和事务的新手。下面按“能不能跑、怎么跑、怎么改”的顺序往下拆。

2. 技术栈与整体架构:一套跑在 IIS 上的 B/S 三层结构

先把技术栈摆清楚,免得你下载下来发现环境对不上。

这套系统的典型组合是ASP.NET WebForms(.NET Framework 4.x)+ ADO.NET + SQL Server,前端是服务器控件加 jQuery,样式多是 Bootstrap 3 或者自己写的一套 CSS,跑在 IIS 上,客户端就是一个浏览器。没有 Node、没有前后端分离、没有 Docker,好处是部署简单——门店一台 Windows 小主机,装好 .NET Framework 和 SQL Server Express 就能用;坏处是所有老 WebForms 的毛病你都得接住:ViewState 大、页面整页回发、打印依赖浏览器控件。

2.1 技术选型:WebForms + ADO.NET + SQL Server 为什么还值得看

WebForms 这套东西现在看确实老,但餐饮门店这个场景反而合适。门店的网络环境差,很多店压根不给你外网,纯 B/S 局域网部署是最省事的;服务员用的收银机可能是五六年前的 Windows 一体机,浏览器还停在某个老内核,跑现代前端框架反而卡。WebForms 的服务器控件把表格、分页、弹窗都封装好了,GridView绑定一个DataTable就能出列表,改一个字段的显示不用动前端构建。

数据访问层一般不会用 Entity Framework,而是手写 ADO.NET,封装一个SqlHelper类,里面是ExecuteNonQuery、ExecuteDataTable、ExecuteScalar三个方法,所有 DAL 类都调它。这种写法在今天的视角下不够优雅,但胜在透明:SQL 写在哪一眼能看到,慢查询加个索引就能见效,出了事不用猜 ORM 生成了什么鬼东西。

提示:拿到源码先看Web.config的targetFramework和compilation节点,确认是 4.0 还是 4.5/4.8。版本对不上,IIS 里应用程序池选错,页面直接给你一个 500.19。

数据库侧常见的是 SQL Server 2008 R2 及以上,业务表十张左右,报表用视图或者存储过程出。金额字段必须用decimal(18,2),别用money,也别用float——这一点后面避坑章节会专门讲。

2.2 分层与请求走向:一次页面回发要经过哪些层

虽然叫“三层”,实际代码里通常是这样切的:

  • 表现层:.aspx页面 +.aspx.cs后台代码,事件写在Page_Load和按钮的OnClick里;
  • 业务层 BLL:DishBLL、OrderBLL、DeskBLL,做参数校验和事务编排;
  • 数据层 DAL:DishDAL、OrderDAL,只负责拼 SQL、调SqlHelper;
  • 实体层 Model:和表一一对应的 POCO 类,字段名跟列名基本一致。

一次“点击下单”按钮的完整走向是这样的:

// Pages/OrderEdit.aspx.cs 里的下单按钮 protected void btnSubmit_Click(object sender, EventArgs e) { // 1. 从 Session 里取当前桌台和购物车 int deskId = Convert.ToInt32(Session["CurrentDeskId"]); List<CartItem> cart = Session["Cart"] as List<CartItem>; if (cart == null || cart.Count == 0) { ClientScript.RegisterStartupScript(GetType(), "msg", "alert('请先选择菜品');", true); return; } // 2. 组装订单主表实体 OrderMain order = new OrderMain { OrderNo = OrderNoHelper.Build(deskId), // 单号:yyyyMMddHHmmss + 桌号 DeskId = deskId, PeopleNum = Convert.ToInt32(txtPeopleNum.Text), Status = 1, // 1=进行中 CreateTime = DateTime.Now, OperUserId = Convert.ToInt32(Session["UserId"]) }; // 3. 交给 BLL,事务在 BLL 里开 int orderId = new OrderBLL().SubmitOrder(order, cart); if (orderId > 0) { Session["Cart"] = null; // 下单成功清购物车 Response.Redirect("OrderDetail.aspx?id=" + orderId); } }

这段代码里几个点值得注意。Session["CurrentDeskId"]是开台时写进去的,页面刷新不会丢,但应用程序池一回收就没了,这是 WebForms 门店系统最常见的“灵异事件”来源。OrderNoHelper.Build生成单号时拼了桌号,是为了后厨小票一眼能认出来是哪桌,比纯时间戳实用。Status = 1是硬编码的魔法值,正规做法是定义一个OrderStatus枚举或者常量类,改的时候全局搜得到。

2.3 登录与会话:Forms 认证 + Session 存角色

权限这块,老项目基本都用 Forms 认证,Web.config里配一段:

<system.web> <authentication mode="Forms"> <!-- 未登录统一跳到登录页,超时 120 分钟,够一个班次用 --> <forms loginUrl="~/Login.aspx" timeout="120" defaultUrl="~/Main.aspx" /> </authentication> <authorization> <deny users="?" /> <!-- 匿名用户一律拒绝 --> </authorization> </system.web>

登录成功后的写法:

// Login.aspx.cs UserInfo user = new UserBLL().Login(txtUserName.Text.Trim(), txtPwd.Text.Trim()); if (user == null) { ClientScript.RegisterStartupScript(GetType(), "msg", "alert('用户名或密码错误');", true); return; } FormsAuthentication.SetAuthCookie(user.UserName, false); // 写认证票据 Session["UserId"] = user.UserId; Session["UserName"] = user.UserName; Session["RoleId"] = user.RoleId; // 1=管理员 2=收银 3=服务员 Session["UserInfo"] = user; Response.Redirect("Main.aspx");

SetAuthCookie的第二个参数是“是否持久化 Cookie”,门店收银机是公用的,一般传false,关浏览器就失效。角色判断建议封装一层,别在每个页面里裸写Session["RoleId"].ToString() == "1":

// App_Code/PermissionHelper.cs public static bool HasRight(string pageKey) { var roleId = HttpContext.Current.Session["RoleId"]; if (roleId == null) return false; // Session 过期,按无权限处理 return new RightBLL().Exists(Convert.ToInt32(roleId), pageKey); }

页面加载时先判权限,没权限直接跳走,别等用户点了一圈才发现按钮是灰的。pageKey一般就是页面文件名,比如DishList、OrderCheckout,权限表里配角色和 pageKey 的对应关系,加页面不用改代码,只加一条配置。

3. 目录结构与数据模型:桌台、菜品、订单三张主表怎么串

拿到源码,先别急着点运行,花十分钟把目录和表结构看一遍,后面改需求能省掉大量翻文件的时间。

3.1 目录拆解:从 App_Code 到 Page 页面

典型的目录长这样(不同源码包名字有出入,但骨架一致):

/RestaurantSys /App_Code -- 公共类:SqlHelper、加密、单号生成、打印工具 /Model -- 实体类,一个表一个 .cs /DAL -- 数据访问层 /BLL -- 业务逻辑层 /Admin -- 后台管理:菜品、分类、桌台、用户、报表 /Pages -- 前台业务:开台、点餐、结账、退单 /Templates -- 小票模板、导出模板 /Images /Css /Js -- 静态资源 /DB -- 建库脚本 + 初始数据 .sql Default.aspx Login.aspx Main.aspx Web.config

看几个关键位置:App_Code是 WebForms 的特殊目录,里面的.cs会被自动编译,不需要加到项目引用里,所以公共类一般扔这;/DB目录最重要,建库脚本和初始数据都在,没有它这套系统跑不起来;/Templates如果有,说明小票是模板化的,改格式只改文本文件不用重编译,这种设计比把格式硬编码在 C# 里好改得多。

页面按模块分文件夹,前台和后台分开,/Pages下的文件名基本就是业务动作:DeskOpen.aspx(开台)、OrderEdit.aspx(点餐)、Checkout.aspx(结账)、OrderBack.aspx(退单/反结账)。找功能按文件名找比按菜单找快。

3.2 主表设计:桌台、菜品、订单主从表

餐饮系统的数据模型核心就四张表,其余都是辅助。

表名关键字段说明
DeskInfoDeskId、DeskName、AreaId、Status、CurrentOrderId桌台,Status是状态机核心
DishInfoDishId、DishName、CategoryId、Price、IsSoldOut菜品,IsSoldOut控制沽清
OrderMainOrderId、OrderNo、DeskId、TotalAmount、RealAmount、Status订单主表,一单一行
OrderDetailDetailId、OrderId、DishId、Price、Quantity、Amount订单明细,加菜就加行

几处设计细节决定了好不好改。OrderDetail里一定要冗余DishName和Price,不能让报表去 join 菜品表取名称和价格——菜名会改、价格会调,历史订单必须保留当时的快照,否则上个月的营业额对不上。OrderMain的金额字段拆成三个:TotalAmount(应收)、DiscountAmount(折扣减免)、RealAmount(实收),结账时三个数都要落库,只存实收的话事后查账查不清折扣是谁给的。

-- 订单主表:金额字段统一用 decimal(18,2),别用 float/money CREATE TABLE OrderMain ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL, DeskId INT NOT NULL, PeopleNum INT NOT NULL DEFAULT 1, TotalAmount DECIMAL(18,2) NOT NULL DEFAULT 0, DiscountAmount DECIMAL(18,2) NOT NULL DEFAULT 0, RealAmount DECIMAL(18,2) NOT NULL DEFAULT 0, PayType TINYINT NULL, -- 1现金 2微信 3支付宝 4会员卡 Status TINYINT NOT NULL DEFAULT 1, -- 1进行中 2已结账 3已退单 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), CheckoutTime DATETIME NULL, OperUserId INT NOT NULL ); CREATE INDEX IX_OrderMain_Desk ON OrderMain(DeskId, Status); CREATE INDEX IX_OrderMain_CreateTime ON OrderMain(CreateTime);

两个索引不是摆设。IX_OrderMain_Desk服务于“查某桌当前有没有未结订单”,开台时每次都查;IX_OrderMain_CreateTime服务于报表按日期范围统计,营业数据攒到几十万行之后,没这个索引月报能转十几秒。

3.3 状态机:桌台状态和订单状态必须对得上

这是最容易出隐性 bug 的地方。桌台状态常见定义:0空闲、1占用、2已预订、3待清台。订单状态:1进行中、2已结账、3已退单。

两者必须成对变更,而且要在同一个事务里:

  • 开台:桌台0 → 1,同时写CurrentOrderId,生成一笔Status=1的订单;
  • 加菜:只加OrderDetail,桌台和订单状态都不动;
  • 结账:订单1 → 2,写CheckoutTime,桌台1 → 0,清空CurrentOrderId;
  • 退单:订单1 → 3,桌台1 → 0。

如果有人只改了订单状态没改桌台,桌台就永久卡在“占用”,第二天早上开门第一件事就是手动去数据库里改状态。这类问题在源码包里非常常见,后面排查章节会给出具体的巡检 SQL。

3.4 报表从哪来:视图和存储过程

营业报表一般不会在 C# 里拼复杂 SQL,而是建视图。常见的三张:

-- 日营业汇总视图:按天统计单量和实收 CREATE VIEW v_DailyReport AS SELECT CONVERT(DATE, CheckoutTime) AS BizDate, COUNT(1) AS OrderCount, SUM(TotalAmount) AS TotalAmount, SUM(DiscountAmount) AS DiscountAmount, SUM(RealAmount) AS RealAmount FROM OrderMain WHERE Status = 2 AND CheckoutTime IS NOT NULL GROUP BY CONVERT(DATE, CheckoutTime);

报表页面上就是一个GridView绑这个视图,加两个日期TextBox做筛选。改报表口径的时候改视图,不用动页面代码,改完刷新就生效,这是这套结构里最舒服的一点。

注意:视图里筛选Status = 2是硬条件,已退单的订单(Status = 3)和进行中的订单都不能算进营业额。见过有源码包漏了这个条件,报表金额虚高,老板一看就急。

4. 核心链路代码走读:一次开台点餐到结账的完整实现

这一章按业务流程走一遍,把关键代码贴出来讲。你二次开发时八成也是在这几个点上动刀。

4.1 开台:一条带条件的 UPDATE 防并发抢台

开台看起来简单——把桌台状态改成占用、生成一张订单。但两个服务员同时点同一张桌,就会开出两张单,后厨收到两份重复小票。防这个的标准做法是“带条件的更新 + 判断受影响行数”:

// DeskBLL.cs 开台 public int OpenDesk(int deskId, int peopleNum, int userId) { using (SqlConnection conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (SqlTransaction tx = conn.BeginTransaction()) { try { // 1. 抢占桌台:只有 Status=0(空闲)才更新得动 string sqlLock = @"UPDATE DeskInfo SET Status = 1 WHERE DeskId = @DeskId AND Status = 0"; int rows = SqlHelper.ExecuteNonQuery(tx, sqlLock, new SqlParameter("@DeskId", deskId)); // rows=0 说明已经被别人抢走了,直接回滚 if (rows == 0) { tx.Rollback(); return -1; } // 2. 生成订单主表 string sqlOrder = @"INSERT INTO OrderMain (OrderNo, DeskId, PeopleNum, Status, CreateTime, OperUserId) VALUES (@OrderNo, @DeskId, @PeopleNum, 1, GETDATE(), @UserId); SELECT SCOPE_IDENTITY();"; object orderId = SqlHelper.ExecuteScalar(tx, sqlOrder, new SqlParameter("@OrderNo", OrderNoHelper.Build(deskId)), new SqlParameter("@DeskId", deskId), new SqlParameter("@PeopleNum", peopleNum), new SqlParameter("@UserId", userId)); // 3. 回写当前订单号 SqlHelper.ExecuteNonQuery(tx, "UPDATE DeskInfo SET CurrentOrderId=@Oid WHERE DeskId=@DeskId", new SqlParameter("@Oid", orderId), new SqlParameter("@DeskId", deskId)); tx.Commit(); return Convert.ToInt32(orderId); } catch { tx.Rollback(); throw; } } } }

UPDATE ... WHERE Status = 0加上行数判断,是一条原子操作,靠数据库的行锁保证只有一个请求能改成功。这比“先 SELECT 查状态、再 UPDATE”安全得多,后者中间有窗口期,并发一上来就翻车。SCOPE_IDENTITY()取刚插入的订单 ID,比@@IDENTITY安全,后者在触发器场景下会取错。

4.2 点餐:购物车放 Session 的取舍

点餐页面的交互是:左边菜品分类树,右边已点列表,点一下加一份,可以改数量、写备注(“不要辣”“少油”),最后一起提交。

购物车绝大多数源码包放在Session里,结构大概这样:

// App_Code/CartItem.cs [Serializable] public class CartItem { public int DishId { get; set; } public string DishName { get; set; } public decimal Price { get; set; } public int Quantity { get; set; } public string Remark { get; set; } // 口味备注,直接打到后厨小票上 public decimal Amount { get { return Price * Quantity; } } }

加菜的逻辑就是先从Session取List<CartItem>,找到同DishId且Remark相同的项就累加数量,找不到就新增一行,然后重新绑Repeater。

放Session的好处是快、代码短;坏处有三个:一是应用程序池回收就丢,服务员点了一半去接个电话,回来购物车空了;二是多开浏览器标签会串;三是没法做“暂存”,客人先去停车回来接着点就没了。

我一般这么处理:购物车仍然放Session,但每次变更都往一张CartTemp表里同步一份(SessionId、DeskId、DishId、Quantity、Remark),页面加载时如果Session为空就从表里回填。多一张表,换掉一整类“购物车莫名清空”的投诉。

4.3 下单落库:一个事务写主表和明细

提交订单是整套系统里最不能出错的一步。主表和明细必须在一个事务里,要么全成功,要么全回滚,绝不能出现“主表有单、明细是空”的脏数据。

// OrderBLL.cs 下单(首次下单和加菜共用一个入口) public int SubmitOrder(OrderMain order, List<CartItem> cart) { using (SqlConnection conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (SqlTransaction tx = conn.BeginTransaction()) { try { // 1. 主表:如果有未结订单则是“加菜”,复用原订单号 int orderId = GetOpeningOrderId(conn, tx, order.DeskId); if (orderId == 0) { orderId = InsertOrderMain(conn, tx, order); // 首次下单 } // 2. 明细逐条插入,金额在服务端重算,不信前端传的值 decimal total = 0m; foreach (var item in cart) { decimal amount = Math.Round(item.Price * item.Quantity, 2, MidpointRounding.AwayFromZero); total += amount; InsertOrderDetail(conn, tx, orderId, item, amount); } // 3. 回写应收金额 UpdateOrderTotal(conn, tx, orderId, total); tx.Commit(); return orderId; } catch (Exception ex) { tx.Rollback(); LogHelper.Write("下单失败", ex); // 落日志,别只弹个框 return -1; } } } }

三个要点。第一,加菜复用订单号:同一桌客人加菜不能生成新订单,否则结账要结好几次,报表单量也不对。第二,金额服务端重算:单价从数据库DishInfo拿,不是从页面隐藏域拿,前端传什么都不可信。第三,异常必须落日志:页面只给用户一句“下单失败,请重试”,日志里要有完整堆栈和orderNo,否则第二天查问题只能靠猜。

Math.Round(..., 2, MidpointRounding.AwayFromZero)这个重载要记住,C# 默认用的是银行家舍入(ToEven),2.345会舍成2.34,跟收银员心算的结果差一分钱,对账的时候能被追着问半天。

4.4 结账与反结账:金额、折扣与桌台释放

结账页要处理折扣、抹零、多种支付方式,还要考虑“结完发现算错了要反结账”。

// Checkout.aspx.cs 结账 public bool Checkout(int orderId, decimal discount, int payType, int userId) { using (SqlConnection conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (SqlTransaction tx = conn.BeginTransaction()) { try { // 1. 锁单:只有进行中的订单能结账,防止重复提交 string sqlLock = "UPDATE OrderMain SET Status=2 WHERE OrderId=@Oid AND Status=1"; if (SqlHelper.ExecuteNonQuery(tx, sqlLock, new SqlParameter("@Oid", orderId)) == 0) { tx.Rollback(); return false; } // 2. 取应收,算实收(抹零规则:向下取整到角) decimal total = GetTotalAmount(conn, tx, orderId); decimal real = Math.Floor((total - discount) * 10) / 10; if (real < 0) real = 0; // 3. 回写金额、支付方式、结账时间 string sqlUpd = @"UPDATE OrderMain SET TotalAmount=@T, DiscountAmount=@D, RealAmount=@R, PayType=@P, CheckoutTime=GETDATE() WHERE OrderId=@Oid"; SqlHelper.ExecuteNonQuery(tx, sqlUpd, /* 参数略 */); // 4. 释放桌台:状态回空闲,清空当前订单 string sqlDesk = @"UPDATE DeskInfo SET Status=0, CurrentOrderId=NULL WHERE CurrentOrderId=@Oid"; SqlHelper.ExecuteNonQuery(tx, sqlDesk, new SqlParameter("@Oid", orderId)); tx.Commit(); return true; } catch { tx.Rollback(); throw; } } } }

第 1 步的锁单是关键:WHERE Status=1保证同一张单只结一次。收银员手快连点两下,或者网络慢导致页面重复提交,都会走到这里,第二次更新影响行数为 0,直接回滚返回失败。

第 4 步释放桌台用的是WHERE CurrentOrderId=@Oid,而不是WHERE DeskId=@DeskId。看着绕,其实更稳:万一这桌被重开过一次,CurrentOrderId已经指向新单了,用DeskId会把新桌台误释放掉。

反结账就是反过来做:订单2 → 1,清空CheckoutTime,桌台0 → 1并回写CurrentOrderId。反结账一定要留痕,单独建一张OrderBackLog表记“谁、什么时候、因为什么”反的,不然月底对账又是一场扯皮。

4.5 后厨出单:小票拼装与打印触发

后厨出单有两种做法:一是接小票打印机走驱动,页面直接调浏览器window.print();二是用厂商的打印控件,客户端装一个 exe。源码包通常两种都留了口子,/Js/print.js里能看到。

拼小票的逻辑在App_Code/PrintHelper.cs里,典型实现是把订单明细拼成一段带制表符的文本:

public static string BuildKitchenTicket(OrderMain order, List<OrderDetail> details) { StringBuilder sb = new StringBuilder(); sb.AppendLine(" 厨 房 单"); sb.AppendLine("--------------------------------"); sb.AppendLine($"桌号:{order.DeskName} 人数:{order.PeopleNum}"); sb.AppendLine($"单号:{order.OrderNo}"); sb.AppendLine($"时间:{order.CreateTime:MM-dd HH:mm}"); sb.AppendLine("--------------------------------"); foreach (var d in details.Where(x => x.Status == 1)) // 只打未出品的 { sb.AppendLine($"{d.DishName} x{d.Quantity}"); if (!string.IsNullOrEmpty(d.Remark)) sb.AppendLine($" * {d.Remark}"); // 口味备注单独一行加星号 } sb.AppendLine("--------------------------------"); return sb.ToString(); }

details.Where(x => x.Status == 1)这个过滤很重要。加菜场景下,第一次下单打过的菜不能再打一遍,否则后厨重复做。所以OrderDetail里要有一个PrintStatus或Status字段,打完更新成“已出品”,这一步在打印成功回调里做,别在拼字符串时就改。

打印失败的处理也要想清楚:打印机没纸、离线、卡纸,程序是弹出提示让人重打,还是静默重试?门店场景建议弹提示 + 保留“重打上一单”按钮,收银员自己能处理,比后台默默失败强。

5. 部署与排查:从还原数据库到 IIS 上线的常见问题

代码看着没问题,跑不起来是常态。这一章按“装环境 → 配配置 → 排查”的顺序来。

5.1 环境准备:.NET Framework、IIS、SQL Server 三件套

门店主机的标准配置:

  • Windows 10 / Server 2012 以上,IIS 7 及以上;
  • .NET Framework 4.5 或 4.8(看Web.config的targetFramework);
  • SQL Server 2008 R2 及以上,Express 版够用,但要确认实例名。

装 IIS 的时候注意勾选“ASP.NET”和“ISAPI 扩展”,很多人只装了默认的静态文件功能,页面一访问就是 404.3。SQL Server 装完要开混合认证模式,并启用 TCP/IP 协议,否则sa账号连不上。

5.2 连接字符串与 Web.config 的关键配置

<connectionStrings> <!-- Data Source 写实例名:默认实例用 . 或 localhost,Express 版写 .\SQLEXPRESS --> <add name="ConnStr" connectionString="Data Source=.;Initial Catalog=RestaurantDB;User ID=sa;Password=你的密码;" providerName="System.Data.SqlClient" /> </connectionStrings> <system.web> <compilation debug="false" targetFramework="4.5" /> <httpRuntime maxRequestLength="20480" /> <!-- 上传菜品图片,单位 KB --> <globalization requestEncoding="utf-8" responseEncoding="utf-8" fileEncoding="utf-8" /> </system.web>

Data Source这一行是新手最容易卡住的地方。默认实例写.或localhost,Express 版必须写.\SQLEXPRESS,写错了报“SQL Server 不存在或访问被拒绝”,看着像密码问题,其实是实例名不对。

debug="true"上线前务必改false,不是性能问题,是安全:黄页错误会把连接字符串和堆栈直接吐到浏览器上。

IIS 侧,应用程序池选.NET CLR v4.0,托管管道模式选经典。WebForms 项目用集成模式大多也能跑,但如果源码里用了URLRewriter之类的 HttpModule,经典模式更省事。如果项目引用了 32 位的打印控件或 Office 组件,还要在应用程序池高级设置里把“启用 32 位应用程序”改成True。

5.3 排查清单:5 个上线当天最容易撞上的坑

坑一:页面开出来一堆问号乱码

现象:菜品名称显示成????,或者中文注释变方块。

原因:三处编码不一致——.aspx文件本身存的不是 UTF-8(有些老源码是 GB2312)、Web.config里没配globalization、数据库排序规则不是Chinese_PRC_CI_AS。

解决:先把文件另存为 UTF-8 带签名,Web.config补上globalization三个属性,再查库:SELECT DATABASEPROPERTYEX('RestaurantDB','Collation'),如果不是中文排序规则,建库脚本里加COLLATE Chinese_PRC_CI_AS重建,或者只对出问题的列单独指定。

坑二:点“打印”没反应,控制台也不报错

现象:点了打印按钮,什么都没发生,换个浏览器还是没反应。

原因:这类系统的小票打印多数依赖客户端控件或者 ActiveX,只认 IE 内核;Chrome、Edge 的新版本直接把这个能力禁掉了。

解决:短期用 IE 模式或者给浏览器装 IE Tab 插件;长期改造走浏览器原生window.print()+ CSS@media print控制小票宽度,虽然排版没控件精细,但不用装任何东西,维护成本低得多。我一般建议门店直接换成这套。

坑三:结账金额和手工算的差一分钱

现象:三份 3.33 元的菜,系统算 9.99,收银员算出来也是 9.99,但加上 8.8 折之后两边对不上。

原因:C# 里decimal默认银行家舍入,SQL 里money类型精度是 4 位,来回转换时取舍规则不一致。

解决:统一用decimal(18,2),所有金额舍入显式写Math.Round(x, 2, MidpointRounding.AwayFromZero),SQL 侧用ROUND(x, 2),别混用money和float。另外折扣计算顺序也要固定:先算折扣再抹零,还是先抹零再打折,差的是真金白银,得跟老板确认清楚。

坑四:桌台卡在“占用”,明明客人早走了

现象:早上开门,好几张桌显示占用,点开台提示“该桌台已被占用”。

原因:结账或退单的代码里,只更新了订单状态,没更新桌台;或者事务中途return没提交。

解决:先手动救急,跑一条巡检 SQL 把孤儿桌台清出来:

-- 找出“桌台占用但没有进行中订单”的脏数据 SELECT d.DeskId, d.DeskName, d.CurrentOrderId FROM DeskInfo d LEFT JOIN OrderMain o ON o.OrderId = d.CurrentOrderId AND o.Status = 1 WHERE d.Status = 1 AND o.OrderId IS NULL;

确认无误后统一UPDATE DeskInfo SET Status=0, CurrentOrderId=NULL WHERE ...。根治要靠代码审查,把“订单状态变更”和“桌台状态变更”封成一个方法,别在两个地方各写一遍。

坑五:点餐点到一半,购物车突然清空

现象:服务员去接了杯水回来,页面还在,已点的菜全没了。

原因:购物车存在Session里,IIS 应用程序池默认空闲 20 分钟回收,Session跟着没了;或者Web.config里sessionState是InProc,进程一重启就丢。

解决:短期把应用程序池的“空闲超时”调到 0(不回收)、“固定时间间隔”调大,但这只是缓解;正经做法是把sessionState改成StateServer或者直接把购物车落库(见 4.2 节的CartTemp方案)。门店系统我倾向于落库,多一张表换来的是再也不用解释“为什么我的菜没了”。

注意:改应用程序池配置属于“治标”,回收不只是空闲触发,内存阈值、配置文件变更都会触发。指望调参数彻底解决Session丢失,迟早还会翻车。

6. 二次开发:加一个套餐模块并验证没改坏原链路

套餐是这类系统最常见的加需求。做法不用太复杂:两张新表,下单时把套餐展开成明细,但小票上只打套餐名。

-- 套餐主表和明细表 CREATE TABLE ComboInfo ( ComboId INT IDENTITY(1,1) PRIMARY KEY, ComboName NVARCHAR(50) NOT NULL, Price DECIMAL(18,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 1 -- 1上架 0下架 ); CREATE TABLE ComboDetail ( Id INT IDENTITY(1,1) PRIMARY KEY, ComboId INT NOT NULL, DishId INT NOT NULL, Quantity INT NOT NULL DEFAULT 1, FOREIGN KEY (ComboId) REFERENCES ComboInfo(ComboId) );

OrderDetail加两个字段:IsCombo(是否套餐父项)和ParentDetailId(子项指向父项)。下单时给套餐插一行父项、价格取套餐价,再给每个组成菜插子项、价格记 0、ParentDetailId指向父项。后厨打印只打IsCombo=1的父项和普通菜,子项过滤掉,避免后厨看到一堆单价 0 的菜发懵;库存扣减和成本核算则走子项,两头都照顾到。

改完必须回归。我自己的习惯是准备一份“结账前快照”:改代码之前,用固定的一桌菜走一遍开台→点餐→加菜→打折→结账,把OrderMain的四个金额字段和桌台状态抄下来;改完之后同样的操作再走一遍,逐字段对比。数字一致才算过,感觉“应该没问题”是最贵的四个字。另外记得在测试库跑,别拿门店正在营业的库试手——有一次我在下午饭点前改了个存储过程,结果整个午市的小票金额都是 0,那顿骂我记到现在。从那以后我每次改核心链路,都强制先在一个空的影子库里跑完整流程,再上生产。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表