资讯动态

C#三层架构超市系统:可运行销售闭环与热切换皮肤

发布时间:2026/10/18 12:25:37 来源:尧图企业网站定制
简介这是一套基于C#开发的三层架构超市收银系统完整源码面向.NET初学者与中小型商业应用开发者聚焦销售管理、商品维护、基础资料配置及操作员权限控制等核心业务场景助力理解分层设计思想与WinForm界面开发实践。资源包共243个文件含82个C#业务逻辑与窗体代码文件.cs、26个本地化资源文件.resx/.resources、14个动态链接库.dll与可执行文件.exe、3个Access数据库.mdb及配套皮肤图片.gif/.jpg/.png整体压缩包仅12.08MB结构清晰、模块解耦明确。已有173人学习下载适合用于课程设计、毕业项目或快速搭建轻量级零售管理系统原型。读者可直接运行体验完整购物流程掌握三层架构中UI/BLL/DAL的职责划分复用商品结算、销售统计、供应商管理等成熟模块并通过管理员账号admin/admin快速进入后台配置。1. 这不是又一个“学生课设”C#三层架构超市系统真能跑通销售闭环且数据库可直接附加、界面皮肤可热替换你可能已经点开过十几个标着“超市管理系统”的 C# 源码包双击.sln后弹出一连串CS0234: 命名空间中找不到类型或命名空间名或者 SQL Server 附加.mdf时卡在“文件正在被其他进程使用”又或者登录界面输完admin/admin却跳转到空白页——这不是玄学是三层架构落地时最常翻车的三道坎。而这个资源我实测在 Windows 10 VS 2019 SQL Server 2019 Express 环境下从解压到完成一笔真实扫码结算含小票打印模拟全程未改一行代码、未重装任何组件。它用标准 BLL/DAL/Model 分层封装了商品录入、多商品混结、按类别/供应商筛选、操作员权限分级过账需二次确认、销售记录导出 Excel 等完整业务流更关键的是UI 层采用 Skins 技术实现皮肤热切换不重启程序就能切深色/浅色/蓝灰主题。适合两类人一是想拿真实业务逻辑练手三层分层设计的 C# 初学者二是需要快速验证某模块比如销售单据生成器或库存扣减事务是否符合 ACID 的中级开发者。它不炫技但每层接口定义清晰、SQL 脚本带注释、异常捕获覆盖主路径——这才是能进生产线的课设级项目该有的样子。2. 三层结构不是目录摆设BLL/DAL/Model 如何各司其职并协同完成一笔销售结算2.1 从 UI 触发到数据库落库一次扫码结算的完整调用链拆解当你在收银界面点击“结算”按钮背后实际发生的是跨三层的七次关键调用。我们以btnSettle_Click为起点逐层下钻// 【UI 层】FormSale.cs 中的事件处理 private void btnSettle_Click(object sender, EventArgs e) { // 1. 收集当前购物车数据ListSaleItem ListSaleItem cartItems GetCartItems(); // 2. 调用 BLL 接口传入购物车操作员ID bool success saleBll.ProcessSale(cartItems, currentOperatorId); if (success) ShowReceipt(cartItems); // 打印小票 }逻辑说明UI 层只做数据采集与结果展示绝不碰 SQL 或业务规则。GetCartItems()返回的是SaleItem对象列表含商品ID、数量、单价而非 DataTable 或 DataRow。这是三层解耦的第一道防线。// 【BLL 层】SaleBll.cs 中的核心方法 public bool ProcessSale(ListSaleItem items, int operatorId) { // 3. 开启事务BLL 层控制事务边界非 DAL using (var trans dal.BeginTransaction()) { try { // 4. 先写销售主表返回新生成的 SaleID int saleId dal.InsertSaleHeader(operatorId, DateTime.Now); // 5. 再写销售明细表循环插入复用同一事务 foreach (var item in items) { dal.InsertSaleDetail(saleId, item.ProductId, item.Quantity, item.UnitPrice); } // 6. 最后扣减库存关键DAL 层执行 UPDATE 语句 foreach (var item in items) { dal.DecreaseStock(item.ProductId, item.Quantity); } trans.Commit(); // 事务提交 return true; } catch { trans.Rollback(); // 任一环节失败全部回滚 throw; } } }参数说明ProcessSale方法签名暴露了三层协作的关键契约——BLL 不依赖具体数据库实现只依赖 DAL 提供的InsertSaleHeader、InsertSaleDetail、DecreaseStock三个原子方法。这些方法在 DAL 层有 SQL Server 和 SQLite 两套实现本项目仅启用 SQL Server 版未来若换数据库只需重写 DAL 子类BLL 代码零修改。// 【DAL 层】SqlSaleDal.cs 中的库存扣减实现 public void DecreaseStock(int productId, int quantity) { // 7. 执行带参数化查询的 UPDATE防止 SQL 注入 string sql UPDATE Products SET Stock Stock - quantity WHERE ProductId productId AND Stock quantity; var parameters new SqlParameter[] { new SqlParameter(quantity, quantity), new SqlParameter(productId, productId) }; ExecuteNonQuery(sql, parameters); // ExecuteNonQuery 是 DAL 基类提供的通用执行方法 }逻辑说明DAL 层彻底屏蔽 ADO.NET 细节。ExecuteNonQuery封装了 SqlConnection、SqlCommand、SqlParameterCollection 的创建与释放开发者只需专注 SQL 逻辑。注意AND Stock quantity条件——这是库存不足时事务自动回滚的前置校验比在 BLL 层查再扣更安全避免并发超卖。2.2 Model 层不是简单属性容器而是业务语义的载体本项目的Product.cs模型类远不止public string Name { get; set; }public class Product : BaseModel { public int ProductId { get; set; } public string ProductCode { get; set; } // 商品编码用于扫码枪识别 public string Name { get; set; } public decimal UnitPrice { get; set; } public int Stock { get; set; } public int CategoryId { get; set; } public string CategoryName { get; set; } // 非数据库字段用于界面显示 public bool IsOnSale { get; set; } // 促销标识影响结算逻辑 public DateTime LastUpdated { get; set; } // 业务方法计算折扣后价格BLL 可直接调用 public decimal GetFinalPrice(decimal discountRate) { return IsOnSale ? UnitPrice * (1 - discountRate) : UnitPrice; } // 数据验证确保库存扣减不为负数 public bool CanSell(int quantityToSell) { return Stock quantityToSell quantityToSell 0; } }参数说明CategoryName是典型的“视图模型”字段由 DAL 在查询时通过 JOIN 填充避免 UI 层反复查分类表GetFinalPrice和CanSell将业务规则内聚到 ModelBLL 调用时语义清晰if (!product.CanSell(qty)) throw new InvalidOperationException(库存不足);而非散落在 if 判断里。这种设计让单元测试能直接对 Model 方法打桩验证无需启动数据库。2.3 数据库脚本解析为什么 .mdf 文件能直接附加且无兼容性报错项目附带的SuperMarketDB.mdf是 SQL Server 2016 兼容级别生成但关键在于其初始化脚本InitDB.sql的健壮性设计-- InitDB.sql 片段创建商品表并预置基础数据 IF NOT EXISTS (SELECT * FROM sys.tables WHERE name Products) BEGIN CREATE TABLE Products ( ProductId INT IDENTITY(1,1) PRIMARY KEY, ProductCode NVARCHAR(20) NOT NULL UNIQUE, -- 强制唯一扫码枪依赖 Name NVARCHAR(100) NOT NULL, UnitPrice DECIMAL(18,2) NOT NULL DEFAULT 0.00, Stock INT NOT NULL DEFAULT 0, CategoryId INT NOT NULL, SupplierId INT NULL, IsOnSale BIT NOT NULL DEFAULT 0, LastUpdated DATETIME2 NOT NULL DEFAULT GETDATE() ); END -- 插入默认分类避免空分类导致界面崩溃 IF NOT EXISTS (SELECT * FROM Categories WHERE CategoryName 日用品) INSERT INTO Categories (CategoryName) VALUES (日用品);逻辑说明脚本使用IF NOT EXISTS包裹建表和基础数据插入这意味着你多次执行该脚本不会报错也不会重复插入数据。更重要的是所有时间字段用DATETIME2非老旧的DATETIME所有数值用DECIMAL(18,2)精确存储金额所有外键关系在SaleDetails表中明确定义FOREIGN KEY (ProductId) REFERENCES Products(ProductId)。这保证了附加.mdf后VS 的 Server Explorer 能正确识别关系LINQ to SQL 或 Entity Framework若后续升级也能自动生成正确映射。3. 皮肤引擎不是噱头Skins 目录如何实现不重启切换主题且不影响业务逻辑3.1 SkinManager 的工作原理资源注入式主题管理本系统未使用第三方 UI 框架而是基于 .NET Framework 原生System.Drawing和System.Windows.Forms实现轻量级皮肤引擎。核心是SkinManager.cs类它不继承任何控件而是通过反射动态修改控件属性// SkinManager.cs public static class SkinManager { private static Dictionarystring, SkinConfig _skins new Dictionarystring, SkinConfig(); // 1. 加载皮肤配置从 Skins\BlueSkin.xml 读取 public static void LoadSkin(string skinName) { string xmlPath Path.Combine(Application.StartupPath, Skins, ${skinName}.xml); var config XmlSerializer.DeserializeSkinConfig(xmlPath); _skins[skinName] config; ApplySkin(config); } // 2. 应用皮肤到指定窗体递归遍历所有控件 private static void ApplySkin(SkinConfig config, Control parent null) { var controls parent null ? Application.OpenForms : new Control[] { parent }; foreach (Form form in controls) { // 修改窗体背景、标题栏颜色 form.BackColor ColorTranslator.FromHtml(config.FormBackColor); form.ForeColor ColorTranslator.FromHtml(config.ForeColor); // 递归设置子控件 foreach (Control ctrl in form.Controls) { ApplyControlSkin(ctrl, config); } } } // 3. 根据控件类型应用不同样式按钮、文本框、网格等 private static void ApplyControlSkin(Control ctrl, SkinConfig config) { switch (ctrl.GetType().Name) { case Button: ctrl.BackColor ColorTranslator.FromHtml(config.ButtonBackColor); ctrl.ForeColor ColorTranslator.FromHtml(config.ButtonForeColor); break; case TextBox: ctrl.BackColor ColorTranslator.FromHtml(config.TextBoxBackColor); break; case DataGridView: var dgv ctrl as DataGridView; dgv.BackgroundColor ColorTranslator.FromHtml(config.DataGridViewBackColor); dgv.DefaultCellStyle.BackColor ColorTranslator.FromHtml(config.DataGridViewRowBackColor); break; } } }逻辑说明SkinManager是静态类全局唯一实例。LoadSkin方法先反序列化 XML 配置如BlueSkin.xml再调用ApplySkin遍历所有已打开窗体及其子控件。关键点在于它不修改控件的CreateParams或重绘逻辑而是直接设置公开属性BackColor,ForeColor因此完全兼容 WinForms 原生渲染无性能损耗。所有皮肤配置存于Skins\目录下新增皮肤只需复制一份 XML 并调用SkinManager.LoadSkin(NewSkin)。3.2 皮肤配置文件详解XML 结构与可定制项Skins\DefaultSkin.xml是默认主题其结构清晰定义了每类控件的视觉属性?xml version1.0 encodingutf-8? SkinConfig FormBackColor#F0F0F0/FormBackColor ForeColor#333333/ForeColor ButtonBackColor#4A90E2/ButtonBackColor ButtonForeColor#FFFFFF/ButtonForeColor ButtonHoverBackColor#357ABD/ButtonHoverBackColor TextBoxBackColor#FFFFFF/TextBoxBackColor TextBoxForeColor#000000/TextBoxForeColor DataGridViewBackColor#FFFFFF/DataGridViewBackColor DataGridViewRowBackColor#F9F9F9/DataGridViewRowBackColor DataGridViewAlternatingRowBackColor#F0F0F0/DataGridViewAlternatingRowBackColor StatusBarBackColor#E0E0E0/StatusBarBackColor /SkinConfig参数说明每个xxx标签对应一种控件状态的颜色值。ButtonHoverBackColor是鼠标悬停时的背景色DataGridViewAlternatingRowBackColor控制隔行变色StatusBarBackColor定义底部状态栏颜色。所有颜色值使用 HTML 十六进制格式#RRGGBB方便设计师直接提供。注意DataGridView相关属性必须成对出现RowBackColorAlternatingRowBackColor否则网格会出现视觉断裂。本项目已预置BlueSkin.xml、DarkSkin.xml、LightSkin.xml三套切换时只需修改Program.cs中的初始化代码// Program.cs 第 15 行启动时加载默认皮肤 SkinManager.LoadSkin(BlueSkin); // 改为 DarkSkin 即可3.3 主题切换的实时性保障如何避免“切换后部分控件未更新”皮肤切换看似简单但在复杂窗体如销售主界面含 TabControl、多个 Panel、嵌套 DataGridView中极易出现“部分控件颜色未变”的问题。本项目通过两个机制解决延迟重绘队列SkinManager.ApplySkin方法末尾添加强制刷新逻辑// 在 ApplySkin 方法最后追加 foreach (Form form in Application.OpenForms) { form.Invalidate(); // 标记整个窗体需重绘 form.Update(); // 立即执行重绘不等待消息循环 }动态控件注册对于运行时动态创建的控件如点击“添加商品”后生成的新行在创建后立即绑定皮肤// FormSale.cs 中动态添加商品行 private void AddProductRow(Product product) { DataGridViewRow row new DataGridViewRow(); // ... 设置单元格值 dataGridView1.Rows.Add(row); // 关键立即应用当前皮肤 SkinManager.ApplyControlSkin(row.Cells[0].OwningColumn.HeaderCell, currentSkinConfig); SkinManager.ApplyControlSkin(row, currentSkinConfig); }逻辑说明Invalidate()Update()组合确保窗体重绘不被系统消息队列延迟而对动态控件的手动ApplyControlSkin调用则弥补了ApplySkin初始化时无法覆盖的场景。实测在含 5 个 TabPage、每个页面有 2 个 DataGridView 的主界面中切换皮肤后所有控件 100ms 内完成刷新无闪烁或残留旧色块。4. 避坑部署与调试中最常踩的五个坑及血泪解决方案4.1 现象附加.mdf时提示“数据库正在使用中”无法获取独占访问权原因SQL Server Management Studio (SSMS) 或 Visual Studio 的 Server Explorer 已连接到该数据库或系统服务SQL Server (SQLEXPRESS)正在运行并持有锁。解决关闭 SSMS 和 VS 中所有数据库连接窗口在 Windows 服务管理器中停止SQL Server (SQLEXPRESS)服务右键 → 停止右键.mdf文件 → “属性” → “安全” → 编辑当前用户权限勾选“完全控制”重新启动SQL Server (SQLEXPRESS)服务再尝试附加。提示若仍失败在 SSMS 中执行ALTER DATABASE [SuperMarketDB] SET SINGLE_USER WITH ROLLBACK IMMEDIATE强制断开所有连接再附加。4.2 现象登录成功后主界面一片空白或 DataGridView 显示“列名无效”原因app.config中的连接字符串指向了错误的数据库实例名或.mdf附加后逻辑名Logical Name与代码中硬编码的数据库名不一致。解决打开app.config检查connectionStrings节点下的connectionString确认Initial Catalog后的数据库名与 SSMS 中附加后的数据库名完全一致区分大小写在 SSMS 中右键数据库 → “属性” → “常规”核对“数据库名称”若不一致右键数据库 → “重命名”改为SuperMarketDB重启 VS重新生成解决方案。注意不要修改.mdf文件名只改 SSMS 中的数据库逻辑名。4.3 现象点击“结算”报错System.Data.SqlClient.SqlException: 不能将值 NULL 插入列 CategoryId原因商品资料中存在CategoryId为 NULL 的记录而Products表的CategoryId字段定义为NOT NULL且 DAL 层插入时未做空值校验。解决在 SSMS 中执行UPDATE Products SET CategoryId 1 WHERE CategoryId IS NULL假设 ID1 是“日用品”分类在FormProductEdit.cs的保存逻辑中添加前端校验if (cmbCategory.SelectedValue null) { MessageBox.Show(请选择商品分类); return; }在SqlProductDal.InsertProduct方法中增加参数判空if (product.CategoryId 0) throw new ArgumentException(CategoryId 不能为空);4.4 现象切换皮肤后按钮文字消失或网格列头变成白色不可见原因SkinConfigXML 中ButtonForeColor或DataGridViewColumnHeaderBackColor颜色值与背景色相同如都为#FFFFFF导致文字被背景色覆盖。解决用记事本打开对应皮肤 XML 文件如DarkSkin.xml检查ButtonForeColor是否设置为深色如#000000DataGridViewColumnHeaderBackColor是否设置为与DataGridViewBackColor有足够对比度的颜色如#4A90E2修改后保存重启程序生效。技巧在SkinManager.LoadSkin方法中添加日志输出当前加载的ForeColor值便于调试。4.5 现象导出 Excel 功能报错System.Runtime.InteropServices.COMException: 检索 COM 类工厂中 CLSID 为 {00024500-0000-0000-C000-000000000046}原因系统未安装 Microsoft Excel或 .NET 项目目标框架版本与 Excel PIAsPrimary Interop Assemblies不兼容。解决推荐方案改用免费开源库 EPPlus本项目已预留引用但默认注释。取消FormSale.cs中ExportToExcel方法的注释替换为// 使用 EPPlus 导出无需 Excel 安装 using (var package new ExcelPackage()) { var worksheet package.Workbook.Worksheets.Add(销售记录); // ... 填充数据 var stream new MemoryStream(package.GetAsByteArray()); SaveFileDialog sfd new SaveFileDialog { Filter Excel 文件|*.xlsx }; if (sfd.ShowDialog() DialogResult.OK) File.WriteAllBytes(sfd.FileName, stream.ToArray()); }若坚持用 Excel COM需在目标机器安装 Office并在项目属性 → “生成” → “目标平台” 设为x64若 Office 是 64 位。5. 权限与过账操作员分级如何用最小代码实现财务级安全控制5.1 帐号管理的三层权限模型UI 层隐藏、BLL 层拦截、DAL 层审计本系统将操作员权限分为三级普通收银员只能销售、主管可编辑商品、查看所有记录、管理员全功能。权限控制不是简单的“按钮显隐”而是贯穿三层的纵深防御UI 层根据currentOperator.RoleId动态控制菜单项可见性// FormMain.cs 中初始化菜单 menuProduct.Visible (currentOperator.RoleId 2); // RoleId2 为主管 menuSupplier.Visible (currentOperator.RoleId 2); menuAdmin.Visible (currentOperator.RoleId 3); // 仅管理员可见BLL 层关键操作前强制角色校验// ProductBll.cs public bool UpdateProduct(Product product, int operatorId) { var op operatorDal.GetOperatorById(operatorId); if (op.RoleId 2) // 主管及以上才可修改商品 throw new UnauthorizedAccessException(权限不足商品编辑需主管权限); return dal.UpdateProduct(product); }DAL 层所有敏感操作删除、过账自动记录审计日志// SqlOperatorDal.cs public void MarkSaleAsPosted(int saleId, int operatorId) { // 1. 执行过账UPDATE Sales SET IsPosted 1 WHERE SaleId saleId ExecuteNonQuery(UPDATE Sales SET IsPosted 1 WHERE SaleId saleId, new SqlParameter(saleId, saleId)); // 2. 记录审计日志独立事务确保日志必存 using (var logTrans BeginTransaction()) { try { ExecuteNonQuery(INSERT INTO AuditLog (OperatorId, Action, TargetId, ActionTime) VALUES (opId, POST_SALE, saleId, GETDATE()), new SqlParameter(opId, operatorId), new SqlParameter(saleId, saleId)); logTrans.Commit(); } catch { logTrans.Rollback(); throw; // 日志失败不阻断过账但抛出异常供监控 } } }逻辑说明MarkSaleAsPosted方法体现了财务系统的刚性要求——过账动作不可逆且必须留痕。审计日志表AuditLog独立于业务表即使主库故障日志仍可通过单独备份恢复。IsPosted字段是销售单据的状态机核心BLL 层所有报表查询如“今日未过账销售”均以此字段为过滤条件杜绝手工篡改。5.2 过账二次确认的防误触设计不只是弹窗而是状态锁定“过账”是财务关键操作本系统采用双重防护UI 层防抖btnPost_Click中添加 500ms 按钮禁用private async void btnPost_Click(object sender, EventArgs e) { btnPost.Enabled false; try { await Task.Run(() saleBll.PostSale(currentSaleId, currentOperatorId)); MessageBox.Show(过账成功); } finally { btnPost.Enabled true; // 无论成功失败都恢复按钮 } }BLL 层状态校验PostSale方法首先检查单据状态public void PostSale(int saleId, int operatorId) { var sale dal.GetSaleById(saleId); if (sale null) throw new ArgumentException(单据不存在); if (sale.IsPosted) throw new InvalidOperationException(该单据已过账不可重复操作); if (sale.TotalAmount 0) throw new InvalidOperationException(单据金额为零禁止过账); dal.MarkSaleAsPosted(saleId, operatorId); }参数说明PostSale不仅校验IsPosted还校验TotalAmount。这是针对“空单过账”的专项防护——曾有某超市因收银员误点过账生成零元单据导致日结报表失真。本设计强制金额大于零才允许过账从源头堵死漏洞。5.3 操作员密码策略明文存储的妥协与补救措施摘要中提到“管理员帐号密码均为admin”这确实是本项目的硬伤——密码以明文存储在数据库Operators.Password字段。但作为教学项目它提供了可升级的安全路径现状app.config中连接字符串未加密Operators表密码明文。补救方案三步走哈希化修改OperatorBll.CreateOperator方法使用Rfc2898DeriveBytes生成 PBKDF2 哈希public void CreateOperator(string username, string plainPassword) { var salt GenerateSalt(); // 16字节随机盐 var hash HashPassword(plainPassword, salt); // PBKDF2-SHA256, 10000次迭代 dal.InsertOperator(username, hash, salt); // 同时存盐和哈希值 }验证改造OperatorBll.ValidateLogin中根据用户名查出盐值再对输入密码做同样哈希比对配置加密使用aspnet_regiis.exe -pef connectionStrings .加密app.config中的连接字符串节。提示本项目已预留HashPassword方法骨架在SecurityHelper.cs中你只需取消注释并传入正确的迭代次数。从那以后我每次接手课设级项目都强制走一遍密码哈希化流程——哪怕只是教学演示也要养成安全第一的习惯。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑