简介:面向餐饮行业信息化入门与课程实践的ASP.NET餐饮管理系统完整源码包,基于ASP.NET Web Forms与三层架构结合SQL Server 2008构建,涵盖菜单、订单、库存、会员、报表及后台权限等常见业务模块,适合计算机专业学生用作课程设计、毕业设计参考,也适合开发者学习Web项目的分层组织方式。压缩包共2000个文件、约46.58MB,以css、gif、js、png等前端静态资源为主,另含dll程序集、cs源码、aspx页面、ashx与asmx服务端点、config配置,以及csproj、sln等解决方案文件,结构接近可直接运行的完整工程。已有949人学习下载,具备一定参考基数。读者可从中获取表现层、业务逻辑层与数据访问层的目录划分方式,业务规则与数据库操作的衔接写法,AJAX与WebService局部刷新的落地示例,以及后台管理页面与权限控制的实现思路,便于对照理解从页面到数据库的调用链路并在此基础上二次改造。
1. 高峰期的账目为什么总对不上
中午十一点四十,四张桌同时下单,收银台那台老电脑卡了两秒,后厨的小票漏了一单,等顾客起身结账时才发现少上了一道菜,于是翻单、补单、重算金额,一桌人多等十分钟。这类问题在小店里太常见,而一套 asp.net 餐饮管理系统要解决的正是这件事——把桌台、菜品、订单、支付这四个状态锁进数据库,让收银员、服务员和后厨看到的是同一份数据,而不是三份各自记在纸上和脑子里的账。
这套东西适合两类人:一类是有 .NET 基础、想接中小餐馆点餐收银的活、或者拿它当毕设课题的开发者;另一类是店里已经有一台 Windows 机器、想自建后台不想按年交订阅费的店主找来的技术朋友。它不追求高并发,追求的是账目对得上、桌台不打架、后厨的出菜单和前台的点菜单是同一张单。
2. 系统定位与技术选型:三层架构怎么拆,为什么还用 WebForm
2.1 从「一台收银机」到「前台 + 后厨 + 管理端」的角色划分
先把角色分清,代码才好落位。这类系统通常拆成四个面向:前台点餐(服务员选桌、选菜、下单、加菜)、收银结账(挂单、并台、打折、支付、开发票备注)、后厨出菜(按订单明细出小票、标记已出菜)、后台管理(菜品维护、分类、桌台配置、员工账号、营业报表)。四个面向共用一套数据,区别只在权限和页面入口。
我一般会把「桌台」当成系统的核心实体,而不是把「订单」当核心。原因是餐馆的真实流程是围绕桌走的:客人落座占台 → 点餐生成订单 → 加菜追加明细 → 结账释放桌台。如果以订单为核心,加菜、并台、换桌这几个动作会写得非常别扭,因为一次用餐可能对应多个订单记录。以桌台为核心,订单只是挂在桌台上的一个「进行中」对象,状态变化更自然。
这也是为什么这套代码里TableInfo表会有Status字段,而OrderInfo表会有TableId外键。很多新手照着电商的订单模型去套餐饮,做到「加菜」那一步就卡住,血泪经验是从桌台反推订单,而不是反过来。
2.2 运行环境与技术栈清单
这类 asp.net 餐饮管理系统多数落在 .NET Framework 而不是 .NET Core,原因很实际:部署目标是餐馆那台 Windows 主机,装 IIS 比装容器简单,客户也不接受为了一个点餐系统去学 Docker。典型技术栈是这样:
| 层次 | 选型 | 说明 |
|---|---|---|
| 运行时 | .NET Framework 4.6.2 / 4.8 | 装在 IIS 上,无需额外运行时 |
| Web 框架 | ASP.NET WebForm(部分页面 MVC 风格) | 事件驱动,页面和控件绑定直接 |
| 数据库 | SQL Server 2012 及以上 | 存储过程 + 事务,账务类数据合适 |
| 数据访问 | ADO.NET + 手写 DAL | 不引 ORM,SQL 可控,排查方便 |
| 前端 | Bootstrap 3/4 + jQuery + LayUI 组件 | 表格、弹窗、日期选择器够用 |
| 部署 | IIS 7.5+ | 应用程序池 .NET CLR 版本要对上 |
| 打印 | 浏览器打印 / 小票机驱动 | 后厨小票走驱动,前台走 A4 或热敏 |
为什么不用 Entity Framework?不是不能,而是这套系统的查询以「按桌台查进行中订单」「按日期汇总营业额」这种带聚合和连接的为主,手写 SQL 反而更好读、更好改。客户说「这个报表加一列」,你改一条 SQL 就行,不用去动模型映射。
前端不追求 SPA,是因为点餐页面的核心诉求是「快」:服务员在平板上点,页面切换不能有白屏。WebForm 的 PostBack 虽然老,但配上 UpdatePanel 做局部刷新,在局域网里响应是够的。我试过把它改成 Vue 前后端分离,结果发现网络请求数量翻了三倍,反而不如原来稳。
2.3 数据库表关系:菜品、桌台、订单、明细四张表的骨架
表结构是这套系统的地基,先看清楚再谈代码。核心是六张表,下面给出最关键的四张,字段做了简化但保留了业务必需的部分。
-- 菜品表:IsOnSale 控制沽清/下架,ImageUrl 给点餐页用 CREATE TABLE Dish ( DishId INT IDENTITY(1,1) PRIMARY KEY, CategoryId INT NOT NULL, DishName NVARCHAR(50) NOT NULL, Price DECIMAL(10,2) NOT NULL, -- 金额一律 decimal,不用 float IsOnSale BIT DEFAULT 1, -- 0=沽清,前台不显示 ImageUrl NVARCHAR(200) NULL, SortNo INT DEFAULT 0 ); -- 桌台表:Status 是状态机的载体,换桌/并台都改这里 CREATE TABLE TableInfo ( TableId INT IDENTITY(1,1) PRIMARY KEY, TableNo NVARCHAR(20) NOT NULL, -- 如 A01、大厅3号 AreaName NVARCHAR(20) NULL, -- 大厅/包间/二楼 Status TINYINT DEFAULT 0, -- 0空闲 1占用 2预订 3待清台 SeatCount INT DEFAULT 4 ); -- 订单主表:OrderNo 用日期+序号生成,便于人工核对 CREATE TABLE OrderInfo ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(30) NOT NULL UNIQUE, TableId INT NOT NULL, UserId INT NOT NULL, -- 开台服务员 OrderTime DATETIME DEFAULT GETDATE(), TotalAmount DECIMAL(10,2) DEFAULT 0, Status TINYINT DEFAULT 0, -- 0进行中 1已结账 2已退单 3挂单 PayType TINYINT NULL -- 1现金 2微信 3支付宝 4银行卡 ); -- 订单明细:Status 独立于主单,后厨按明细出菜 CREATE TABLE OrderDetail ( DetailId INT IDENTITY(1,1) PRIMARY KEY, OrderId INT NOT NULL, DishId INT NOT NULL, Qty INT NOT NULL, Price DECIMAL(10,2) NOT NULL, -- 下单时快照菜价,防止后续改价影响历史单 Remark NVARCHAR(100) NULL, -- 少辣、不要葱 Status TINYINT DEFAULT 0 -- 0待制作 1已出菜 2已退菜 );几个字段值得单独说。OrderDetail.Price存的是下单瞬间的菜价快照,不是每次都去关联Dish.Price查。原因很直接:老板今天调价,昨天的单不能跟着变。这个字段是很多初版代码漏掉的,等到月末对账发现历史订单金额和实际收款不一致,再补就很麻烦。
TableInfo.Status用TINYINT而不是字符串,是为了做状态迁移判断时能直接写WHERE Status = 0。OrderDetail.Status和OrderInfo.Status各管各的:主单已结账,明细里仍可能有一条「已退菜」,报表统计退菜率要靠它。
索引方面至少给这三处加上,不然数据过万之后报表页面会转圈:
CREATE INDEX IX_Order_Time ON OrderInfo(OrderTime); CREATE INDEX IX_Order_Table ON OrderInfo(TableId, Status); CREATE INDEX IX_Detail_Order ON OrderDetail(OrderId);2.4 三层目录结构怎么放
资源包的目录通常是这个形态,看懂了再改代码不会迷路:
RestaurantSys/ ├── Model/ # 实体类,和表一一对应 │ ├── Dish.cs │ ├── TableInfo.cs │ └── OrderInfo.cs ├── DAL/ # 数据访问,手写 SQL 集中在这里 │ ├── SqlHelper.cs # 连接、ExecuteNonQuery、ExecuteDataTable │ ├── DishDAL.cs │ └── OrderDAL.cs ├── BLL/ # 业务逻辑,事务、状态判断写这层 │ ├── OrderBLL.cs │ └── TableBLL.cs ├── Web/ # 页面 │ ├── Login.aspx │ ├── Order/OrderAdd.aspx │ ├── Cashier/Checkout.aspx │ ├── Admin/DishList.aspx │ └── Web.config └── DB/ └── restaurant.sql # 建库脚本 + 初始数据判断一份代码值不值得改,我看两点:DAL里有没有一个统一的SqlHelper,以及BLL里有没有事务封装。如果每个页面都new SqlConnection然后拼字符串,那这套代码的维护成本会随着需求增长指数上升,改造成本比重写还高。如果SqlHelper存在、BLL有ExecuteTransaction这类方法,那基本可以放心在上面加功能。
3. 环境搭建与首次跑通:IIS 配置、连接串和数据库还原
3.1 环境清单与安装顺序
顺序错了会浪费一下午。我的习惯是按依赖从底往上装:
- SQL Server(Express 版够用),安装时记住实例名,默认实例是
MSSQLSERVER,Express 版通常是SQLEXPRESS。 - SSMS(SQL Server 管理工具),用来还原脚本和查数据。
- .NET Framework 4.8,Win10 以上一般自带,Win7 或 Server 2012 需要手动装。
- IIS,在「启用或关闭 Windows 功能」里勾选,注意要勾上「ASP.NET 4.x」和「ISAPI 扩展」。
- Visual Studio(可选,只跑不改可以不装,但改代码基本躲不开)。
一个常被忽略的点:IIS 装完之后必须注册 ASP.NET,否则访问.aspx会直接 404 或者提示处理程序未安装。命令行执行一次就行:
# 以管理员身份运行,路径按实际 .NET 版本调整 C:\Windows\Microsoft.NET\Framework64\v4.0.30319\aspnet_regiis.exe -i执行完在 IIS 里刷新一下,应用程序池列表里能看到.NET CLR v4.0的池子,说明注册成功。
3.2 Web.config 里必须动的四处配置
Web.config是这套系统的黑匣子,跑不起来八成问题在这里。下面四段是必须检查的。
<configuration> <!-- 1. 连接串:Data Source 写实例名,AttachDbFilename 只在附加模式用 --> <connectionStrings> <add name="ConnStr" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=RestaurantDB; User ID=sa;Password=你的密码;MultipleActiveResultSets=True" providerName="System.Data.SqlClient" /> </connectionStrings> <system.web> <!-- 2. 编码:中文菜名乱码基本都是这里没设 --> <globalization requestEncoding="utf-8" responseEncoding="utf-8" fileEncoding="utf-8" /> <!-- 3. 会话:默认 InProc,IIS 回收就会丢购物车 --> <sessionState mode="InProc" timeout="120" /> <!-- 4. 上传限制:菜品图片上传超过 4MB 会直接报错 --> <httpRuntime maxRequestLength="10240" targetFramework="4.8" /> </system.web> <system.webServer> <!-- 集成模式下必须保留,否则静态资源和路由会 404 --> <modules runAllManagedModulesForAllRequests="true" /> </system.webServer> </configuration>connectionStrings里的Data Source最容易写错。用 SSMS 连的时候显示的是机器名\SQLEXPRESS,但配置里写.\SQLEXPRESS或(local)\SQLEXPRESS更保险,因为换机器部署时机器名会变。如果用 SQL 身份验证,记得在 SSMS 里把服务器属性改成「SQL Server 和 Windows 身份验证模式」,否则sa登录会被拒。
sessionState的timeout="120"是给点餐场景调的。默认 20 分钟,服务员把平板放下去忙别的,回来购物车就空了,用户会以为系统坏了。改成 120 分钟,配合后面第 5 章讲的回收设置,基本够用。
3.3 数据库附加与初始化数据
拿到DB/restaurant.sql之后,两种方式都行。用 SSMS 打开脚本直接执行,适合全新环境;用「附加数据库」挂.mdf文件,适合资源包里带了数据文件的情况。命令行还原的写法:
# 先建空库,再用 sqlcmd 跑脚本,适合没有 GUI 的服务器 sqlcmd -S .\SQLEXPRESS -U sa -P 你的密码 -Q "CREATE DATABASE RestaurantDB" sqlcmd -S .\SQLEXPRESS -U sa -P 你的密码 -d RestaurantDB -i D:\RestaurantSys\DB\restaurant.sql执行完之后验证三件事:Dish表有初始菜品数据(一般几十条,覆盖凉菜、热菜、主食、饮料几个分类)、TableInfo有桌台记录、SysUser有管理员账号。如果只有表结构没有数据,说明脚本导的时候没勾「架构和数据」,得回去重新导一份。
初始管理员账号通常在脚本注释里写着,常见是admin / 123456。第一次登录后立刻改密码,别留默认值——餐馆的网络虽然是内网,但点餐用的平板可能连的是公共 WiFi,这个不是危言耸听。
3.4 默认账号登录与权限验证链路
登录逻辑不长,但它是理解整套权限设计的入口。多数实现是「页面基类 + Session 校验」的路子:
// BasePage.cs —— 所有需要登录的页面继承它 public class BasePage : System.Web.UI.Page { protected override void OnInit(EventArgs e) { base.OnInit(e); // 未登录直接踢回登录页,带上原始地址便于登录后跳回 if (Session["UserId"] == null) { Response.Redirect("~/Login.aspx?returnUrl=" + Server.UrlEncode(Request.RawUrl)); return; } // 角色校验:页面可以声明自己允许哪些角色访问 var allowRoles = this.GetType().GetCustomAttributes(typeof(RoleAttribute), true); if (allowRoles.Length > 0) { var role = (allowRoles[0] as RoleAttribute).Roles; var currentRole = Session["RoleId"].ToString(); if (!role.Contains(currentRole)) { Response.Redirect("~/NoPermission.aspx"); } } } }OnInit里做拦截而不是Page_Load,是因为Init阶段控件还没绑定数据,早拦截能省掉一次无谓的数据库查询。returnUrl参数看着小,实际体验差别很大:收银员点开一个订单详情页被踢去登录,登录完应该回到那个页面,而不是回到首页重新找。
角色校验用自定义特性RoleAttribute挂在页面类上,写法是[Role("1,2")],表示收银员和管理员可访问。这种做法的好处是把权限声明和页面放在一起,改的时候不用去翻配置文件。如果资源包里是集中式的菜单表权限,逻辑类似,只是改成查SysRoleMenu表再比对,思路一样。
4. 核心模块拆解:一笔订单从点餐到结账的完整链路
4.1 点餐车:Session 暂存还是直接落库
这是个必须提前定的事,因为两种方案的后续代码完全不同。
Session 方案:服务员点菜时只往Session["Cart"]里塞一个List<OrderDetail>,点「提交」才一次性写库。好处是响应快、不产生垃圾订单;坏处是 Session 一丢购物车就没了,而且多台平板之间不共享。
直接落库方案:每点一道菜就写一条OrderDetail,状态为「待制作」。好处是数据实时、多端共享、后厨能立刻看到;坏处是服务员反复加删会产生大量状态变更记录。
这套系统我倾向于混合:购物车放 Session,但每 30 秒或每次加菜后异步落一次草稿单(OrderInfo.Status = 3挂单)。这样既能快速响应,又能在 Session 丢失时从草稿单恢复。实际写的时候,购物车结构就是:
// CartItem 只存必要字段,展示用的菜名从内存字典取 [Serializable] public class CartItem { public int DishId { get; set; } public string DishName { get; set; } public decimal Price { get; set; } public int Qty { get; set; } public string Remark { get; set; } } // 加入购物车:同菜同备注则数量累加,否则新增一行 public void AddToCart(int dishId, string dishName, decimal price, int qty, string remark) { var cart = Session["Cart"] as List<CartItem> ?? new List<CartItem>(); var exist = cart.FirstOrDefault(c => c.DishId == dishId && c.Remark == remark); if (exist != null) exist.Qty += qty; // 加菜:数量累加,不新增行 else cart.Add(new CartItem { DishId = dishId, DishName = dishName, Price = price, Qty = qty, Remark = remark }); Session["Cart"] = cart; }Remark参与去重判断是关键细节。同一道菜「微辣」和「不辣」在厨房是两份活,不能合并成数量 2。这个判断条件写错,后厨就会收到「水煮鱼 x2 微辣」而实际上有一份是不辣的,出菜直接被退回。
Session["Cart"]存的是List<CartItem>,需要类标记[Serializable],否则切到 StateServer 或 SQLServer 会话模式时会报序列化失败。这个坑在第 5 章细说。
4.2 提交订单:一个事务里要写完的四张表
提交订单是整套系统里最不能出错的一段。它要同时完成:插入主单、批量插入明细、更新桌台状态、扣减库存(如果有)。任何一步失败都必须整体回滚,否则会出现「桌台占了但没有订单」或者「订单有但桌台还是空闲」这类脏数据。
// OrderBLL.SubmitOrder —— 开台下单的完整事务 public int SubmitOrder(int tableId, int userId, List<CartItem> cart, out string msg) { msg = string.Empty; // 前置校验:桌台必须是空闲状态,防止两个人同时开同一张台 var table = tableDAL.GetById(tableId); if (table == null || table.Status != 0) { msg = "该桌台已被占用,请刷新后重试"; return -1; } decimal total = cart.Sum(c => c.Price * c.Qty); string orderNo = DateTime.Now.ToString("yyyyMMddHHmmss") + tableId.ToString("D2"); using (var conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 1. 插主单,返回自增 OrderId int orderId = orderDAL.InsertOrder(conn, tran, orderNo, tableId, userId, total); // 2. 明细逐条插入,SqlBulkCopy 或循环均可,量小用循环 foreach (var item in cart) { orderDAL.InsertDetail(conn, tran, orderId, item.DishId, item.Qty, item.Price, item.Remark); } // 3. 更新桌台状态为占用;带 Status=0 条件,天然防并发 int affected = tableDAL.UpdateStatus(conn, tran, tableId, 1); if (affected == 0) { // 说明在校验之后被别人抢先开台了 throw new Exception("桌台状态已变更,请重试"); } tran.Commit(); return orderId; } catch (Exception ex) { tran.Rollback(); msg = ex.Message; return -1; } } } }逐段说清楚。第一段的前置校验只是快速失败,真正的并发保护在第 3 步:UPDATE TableInfo SET Status = 1 WHERE TableId = @id AND Status = 0,这条语句带上了Status = 0条件,数据库层面保证只有一个人能改成功,返回受影响行数为 0 就说明被人抢先。这是最省事的乐观锁写法,比在应用层加锁靠谱得多。
第二步逐条插入明细,菜品数量通常在 5 到 30 之间,循环插入完全够用,没必要上SqlBulkCopy。但循环内部必须复用同一个conn和tran,如果InsertDetail自己又开了连接,事务就断了,回滚时明细会残留。
第三步的返回值判断是这段代码里最容易被删掉的一行。很多人觉得「前面的校验已经查过状态了,这里肯定成功」,直接不判断。结果就是高峰期两个人同时开一张台,两张订单都插入成功,只有一张能结账,另一张永远挂着。
orderNo的生成规则里拼了tableId,是为了避免同一秒内多桌下单导致编号重复。加了桌台号后,理论碰撞概率降到可以忽略,UNIQUE约束也不会误伤。
4.3 桌台状态机:空闲、占用、预订、待清台
桌台的状态不是随便改的,得有明确允许的迁移路径,否则会出现「空着的桌显示已预订但查不到预订记录」这种玄学问题。这套系统的状态和允许的动作是:
| 当前状态 | 允许动作 | 目标状态 |
|---|---|---|
| 0 空闲 | 开台下单 | 1 占用 |
| 0 空闲 | 预订 | 2 预订 |
| 2 预订 | 客人到店开台 | 1 占用 |
| 2 预订 | 取消预订 | 0 空闲 |
| 1 占用 | 加菜 / 换桌 | 1 占用(TableId 变) |
| 1 占用 | 结账 | 3 待清台 |
| 3 待清台 | 清台完成 | 0 空闲 |
换桌这个动作要单独说。它不是改一下OrderInfo.TableId就完事,而是三件事一个事务:旧桌台状态改回空闲(或待清台)、新桌台改占用、主单的TableId更新。漏掉任何一步,旧桌台就会一直显示占用,直到有人手动去数据库改。代码里通常封装成ChangeTable(int orderId, int newTableId),内部用UPDATE ... WHERE Status = 0保证新桌台确实是空的。
「待清台」这个中间状态看着多余,实际很有用。客人走了但桌子还没收,服务员不能马上把新客人安排过去;有了这个状态,收银员结完账点一下「已清台」,状态才回到空闲。少了这一步,桌台图上全是「空闲」但其实还没收拾,迎宾会把客人领到脏桌子上。
状态判断尽量只在一处做,别在页面if里散落。我的习惯是在TableBLL里写死CanOccupy、CanCheckout这类方法,页面只调方法,不直接比对数字。这样以后加「预结」状态时,改动范围可控。
4.4 结账与日结:金额计算与支付方式
结账的核心不是算钱,而是把「进行中」的订单变成「已结账」并留下不可改的流水。金额计算部分要考虑优惠、抹零、服务费,这些规则各店不一样,代码里通常留了配置项。
// Checkout —— 结账时重算金额,不信任前端传来的 total public bool Checkout(int orderId, decimal discount, decimal paid, int payType, out string msg) { msg = string.Empty; var order = orderDAL.GetById(orderId); if (order == null || order.Status != 0) { msg = "订单状态异常"; return false; } // 1. 以明细重新汇总,防止前端篡改金额 decimal realTotal = orderDAL.SumDetailAmount(orderId); // 2. 抹零规则:常见的是保留到元,或满 100 减 5 decimal payable = Math.Round(realTotal - discount, 2); if (payable < 0) payable = 0; // 3. 实收不足直接拒绝;多收记为找零,不入账 if (paid < payable) { msg = "实收金额不足"; return false; } using (var conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { // 4. 更新主单:金额、支付方式、状态、结账时间 orderDAL.Settle(conn, tran, orderId, payable, payType); // 5. 写支付流水,退款/对账都靠它 payDAL.Insert(conn, tran, orderId, payable, payType); // 6. 桌台进入待清台,不直接置空闲 tableDAL.UpdateStatus(conn, tran, order.TableId, 3); tran.Commit(); return true; } } }第 1 步用明细重算是安全底线。前端传来的total只能是展示用的,后端一律自己算一遍。中间加过菜、退过菜的单子,前端金额和后端金额不一致是常态,信前端就是给自己埋雷。
第 3 步把「多收」当找零处理而不是记入营收,这个判断标准要跟店里对清楚。有的店允许把多收的几毛钱留在账上做「长款」,那就得单独记一个科目,不能混在营业额里,否则日结时现金抽屉对不上。
日结报表其实是同一套数据的另一种聚合:
-- 按支付方式汇总当天的实际收款 SELECT PayType, COUNT(*) AS OrderCount, SUM(TotalAmount) AS TotalAmount FROM OrderInfo WHERE Status = 1 AND OrderTime >= @dayStart AND OrderTime < @dayEnd GROUP BY PayType;@dayStart和@dayEnd要注意是「当天 00:00:00 到次日 00:00:00」,不是BETWEEN 00:00 和 23:59:59。后者会漏掉23:59:59.500这样的时间点,SQL Server 的DATETIME精度是 3.33 毫秒,跨零点那一秒的单子会从报表里消失。这种小数点级别的问题,查起来特别费劲。
5. 部署与联调排查:六个最容易翻车的地方
5.1 页面打开是空白或 500,日志里什么都没有
现象:浏览器访问首页转圈,最后 500,IIS 日志里只有一行 500.0,没有堆栈。
原因:Web.config里customErrors开着On,真实异常被藏起来,只返回一个友好页面。
解决:本地调试阶段把customErrors mode="Off"打开,重新访问就能看到黄页详情。上线前记得改回RemoteOnly,不然异常堆栈会暴露连接串片段。
5.2 中文菜名全变成问号或方块
现象:数据库里存的中文在页面上显示成???,但用 SSMS 查又是好的。
原因:Web.config的globalization没配,或者页面文件本身被存成了 ANSI 编码。
解决:先加globalization requestEncoding="utf-8";再看.aspx文件头有没有<meta charset="utf-8">;最后检查Dish表的字符串字段是不是NVARCHAR而不是VARCHAR。三个地方有一个不对就会出现乱码,而且现象一样,得逐个排除。
5.3 服务员点了十几道菜,提交时购物车是空的
现象:点餐页面操作正常,点提交提示「购物车为空」,刷新后确实没了。
原因:IIS 应用程序池的闲置超时默认 20 分钟,池子一回收,InProc模式的 Session 全部清空。加上店里服务员经常把平板放下十几分钟不动,撞上回收太正常了。
解决:短期把应用程序池「闲置超时」改成 0(不回收)或 480 分钟;中期把sessionState改成StateServer或SQLServer模式,注意这两个模式要求 Session 里存的类必须标[Serializable],否则会报「无法序列化会话状态」。
5.4 部署到 IIS 上访问报 404.17 或「无法识别的配置节」
现象:在 VS 里跑得好好的,挂到 IIS 上直接 404.17 或者启动就报配置错误。
原因:应用程序池的 .NET CLR 版本是「无托管代码」或 v2.0,而项目是 4.x;或者池子用了「经典模式」但代码里用了system.webServer的配置节。
解决:应用程序池改成.NET CLR v4.0+ 「集成模式」;再跑一次aspnet_regiis -i注册处理程序。如果代码是从老项目迁过来的、依赖经典模式,那就把Web.config里的system.webServer段挪到system.web里,别硬改池子。
5.5 两张桌同时开台,其中一张的订单查不到
现象:高峰期后厨收到两份单,但前台的桌台图上只有一张桌是「占用」,另一张显示空闲,结账时找不到单。
原因:开台时的UPDATE没带AND Status = 0条件,两个请求都更新成功,最后一次覆盖了前一次;或者事务没包住,明细插进去了、主单更新失败。
解决:检查UpdateStatus的 SQL 是否带条件判断,检查SubmitOrder里所有 DAL 方法是否复用同一个conn和tran。已经产生的脏数据用一条 SQL 找出来:查OrderInfo.Status = 0但对应TableInfo.Status != 1的记录,手动补状态。
5.6 后厨小票打不出来,但页面显示「已下单」
现象:前台提交成功,后厨没出小票,服务员以为没下单又点了一遍,结果出了双份菜。
原因:打印走的是浏览器window.print(),弹窗被浏览器拦截,或者小票机驱动没装、默认打印机选到了 A4。
解决:打印调用前先判断返回值,失败就把订单标成「待打印」并在收银台亮红点提示,别静默失败。更稳的做法是后厨单独跑一个轮询程序(或者浏览器常驻页面),按OrderDetail.Status = 0主动拉取待出菜明细,打印成功再回写状态,这样打印机坏了也不会丢单。
6. 二次开发:给这套系统加一个扫码点餐入口
改造点选扫码点餐,因为它能把这套系统的模块边界暴露得最清楚。原系统的点餐入口是服务员登录后的页面,加扫码意味着要开一个无需登录的入口,同时不能让人随便改别人的单。我的做法是不动原有权限体系,只在Order目录下加一个独立页面ScanOrder.aspx,走桌台令牌校验。
第一步,给桌台表加一个令牌字段,一桌一码,换码即失效:
ALTER TABLE TableInfo ADD QrToken VARCHAR(64) NULL; -- 为每张台生成一个随机令牌,打印成二维码贴在桌上 UPDATE TableInfo SET QrToken = REPLACE(NEWID(), '-', '') WHERE QrToken IS NULL;第二步,页面用?t=令牌识别桌台,校验通过后按现有TableBLL的逻辑开台或追加。关键是把「谁下的单」标记清楚:
// 扫码进来的单子,UserId 统一记 0(表示顾客自助),便于报表区分 protected void btnSubmit_Click(object sender, EventArgs e) { string token = Request.QueryString["t"]; var table = tableBLL.GetByToken(token); if (table == null) { ShowMsg("二维码无效,请联系服务员"); return; } // 已有进行中的订单就追加,没有就新开台 int orderId = orderBLL.GetRunningOrderId(table.TableId); if (orderId > 0) orderBLL.AppendDishes(orderId, GetCartFromSession()); else orderBLL.SubmitOrder(table.TableId, 0, GetCartFromSession(), out _); Response.Redirect("ScanSuccess.aspx?t=" + token); }AppendDishes是新加的方法,内部只做两件事:插明细、重算主单金额,不动桌台状态。这样顾客扫码加菜和服务员手工加菜走的是同一条明细插入路径,后厨不用区分来源。
第三步,加一个防刷限制。令牌虽然随机,但二维码能被拍照传播,所以我在ScanOrder.aspx里加了「同一令牌 5 分钟内只允许新开一次台」的判断,超过就要求服务员在收银台确认。这个限制放在BLL里而不是页面里,因为以后小程序端也要接,规则得共用。
三个改造点下来,真正需要新写的代码不到 200 行,剩下全是复用。这也是我判断一套餐饮系统架构成不成熟的标准:能不能在不碰核心事务的前提下加一个新入口。如果加扫码要改SubmitOrder的签名,说明参数设计一开始就把场景写死了。
改造完之后一定要跑一遍完整的回归:空桌扫码下单、占用桌扫码加菜、结账后令牌失效、两个人同时扫同一张桌。我吃过一次亏——加菜路径忘了重算主单金额,导致扫码加的菜不计入账单,顾客结账时少付了三十多块,最后还是看日结报表的笔数对不上才发现的。从那以后我每次动订单相关代码,都强制走一遍「下单 → 加菜 → 退菜 → 结账 → 对报表」这条链路,一步不落。希望这些能帮到你。
本文还有配套的精品资源,点击获取