简介:基于C#三层架构实现的超市收银管理系统,附带完整源码与数据库文件,面向C#初学者、毕业设计及课程实训人群,能够提供一套可直接运行的进销存业务闭环参考。系统功能全面,包括销售管理中的商品结算、商品信息与商品资料维护、基础资料编辑(商品单位、类别、供应商资料)、销售记录(今日/全部)以及操作员账号管理与过账等模块,配合漂亮皮肤界面,并预置管理员账号admin,便于快速登录体验。压缩包内共243个文件,以82个.cs源文件为核心,搭配26个.resx与26个.resources界面资源、14个DLL和PDB编译文件,以及GIF操作示意、说明文档、数据库.mdb和项目工程文件。整体仅12.08MB,下载后还原到Visual Studio即可查看表示层、业务层(BLL)、数据访问层的分层结构。目前已有173人学习下载,适合需要完整三层架构项目参考、希望快速上手超市管理系统开发的读者。
1. 这套三层架构超市管理系统到底能拿来做什么
先给结论:如果你是在校生、刚入门的 .NET 开发者,或者只是想把"三层架构"真正落进代码里看懂一次,这套基于 C# 的超市管理系统源码加数据库压缩包,是性价比很高的起步素材。很多人以为超市管理系统核心技术是收银、是扫码、是结账速度,实际拉开差距的恰恰是背后的架构——界面层、业务层、数据层各管各的事,改商品加价不用动界面代码,换数据库不用改业务逻辑。这份源码的价值也正在这里:它用最小的业务范围(超市进销存),把三层架构的依赖方向、类职责和数据流讲清楚了。
网上类似的课程设计代码很多,但大部分是"所有按钮事件挤在 Form1.cs 里、SQL 直接写在界面层"的三层皮。真正值得下下来看的,是它怎么处理业务校验、事务和数据库连接的关闭时机。这篇笔记会把架构拆开讲,拿关键代码说清楚每一层怎么写、参数怎么调、哪些地方最容易翻车,让你拿到压缩包后能跑起来、能改得动,而不是解压完看一眼就换个文件继续躺硬盘。
2. 超市管理系统的三层架构选型:先想清楚为什么分这三层
2.1 三层架构每一层在管什么事
三层架构说的是表现层(UI)、业务逻辑层(BLL)、数据访问层(DAL),加上一个经常被忽略的实体层(Model)。表现层只负责用户看到什么、点了什么;业务层处理"能不能做"的规则;数据层只回答"数据存哪、怎么取"。依赖关系是单向的:UI 层引用 BLL 和 Model,BLL 引用 DAL 和 Model,DAL 引用 Model。
这套系统的典型业务是收银台结账。界面层拿到商品条码,把条码丢给业务层;业务层去数据层查价格、查库存,再判断库存够不够,够才允许扣减并生成销售记录;最后把结果返回界面层。如果哪天你改成扫码枪输入,只需要动界面层,业务层不用碰;如果你想从 SQL Server 换成 MySQL,只需要重写 DAL 层,界面层依然不用碰。这就是分层的全部意义。
验证一件事:去源码里找 Form 或者 Page 文件,如果里面直接出现了SqlConnection、SqlCommand、SELECT这种字眼,说明这个项目只是"看起来分层",实际上 SQL 泄漏到了界面层。真正的三层架构里,界面层不应该出现任何一个数据库对象。
2.2 为什么是 WinForms + SQL Server 而不是 MVC 或者 WPF
这是这套源码最常见的技术组合。WinForms 拖控件快、事件模型直观,对初学者来说"双击按钮写代码"是最容易理解的人机交互方式,也最适合学校机房和课程设计的环境。MVC 虽然也是三层思想的体现,但它的 Model-View-Controller 是界面内部的分工,和这里说的"三层"根本不是同一个维度的东西,很多初学者把这两个概念混在一起,导致拿 MVC 项目去套三层架构的理解,越套越乱。
数据库选 SQL Server 的理由更简单:和 C# 同属微软生态,SqlConnection直接能用,学校机房基本都装了,安装包也好找。如果你想换成 MySQL,需要改System.Data.SqlClient为MySql.Data.MySqlClient,同时把连接字符串、参数前缀从@改成?,工作量不算大,但涉及面很广,不建议第一次跑通就去换库。
2.3 拿到压缩包先确认这四件事
解压后别急着双击.sln,先花两分钟把包内结构看清。我一般会按这个顺序检查:
- 有没有
.sln解决方案文件,它是整个项目的入口; - 有没有
App.config或Web.config,里面是连接字符串,是它和数据库对话的凭证; - 有没有
.mdf/.ldf或.bak文件,这是数据库本体,没有它项目跑不起来; - 项目是
.NET Framework还是.NET Core,直接决定你能不能用 Visual Studio 2019 / 2022 打开。
这里说一个经验:很多打包下载的项目,App.config里的连接字符串指向的是作者本机的 SQL Server 实例名,比如localhost\\SQLEXPRESS或者带上作者电脑名的实例。你的电脑上不一定有这个名字的实例,所以拿到手第一件事就是改这一行,改完再说别的。具体怎么改,后面章节会拿代码一步步说。
3. 数据库建模先行:超市系统的表结构与建库脚本
3.1 超市进销存业务需要哪几张核心表
超市管理系统不管界面多花哨,核心业务都绕不开三件事:商品档案、进货入库、销售出库。所以数据库最少要有这几张表:商品信息表(Product)、员工表(Employee)、用户表(User)、销售单主表(Sale)、销售明细表(SaleDetail)、进货单主表(Purchase)、进货明细表(PurchaseDetail)。
主表和明细表为什么要分开?以销售为例,一次结账会涉及多个商品,每件商品一行,但"这笔订单的时间、收银员、总金额"属于订单本身的属性。如果全塞一张表里,每件商品都要重复一遍订单时间、收银员,既冗余又容易错。正确做法是主表存一次订单信息,明细表通过订单编号关联每一件商品。这是数据库第一范式的基本要求,也是这套源码里最值得看的部分。
建库脚本可以直接用下面的 SQL。注意脚本里我故意把商品表加了一个IsDelete字段,用来做逻辑删除,而不是物理删除——超市的商品价格有历史记录,结账单要追溯当时的商品快照,直接 DELETE 掉一条商品会让历史统计对不上账。
CREATE DATABASE SupermarketDB; GO USE SupermarketDB; GO CREATE TABLE Product ( ProductId INT IDENTITY(1,1) PRIMARY KEY, ProductCode NVARCHAR(50) NOT NULL UNIQUE, -- 商品条码,扫码收银的依据 ProductName NVARCHAR(100) NOT NULL, Category NVARCHAR(50) NULL, UnitPrice DECIMAL(10,2) NOT NULL, -- 销售单价,两位小数够用 Stock INT NOT NULL DEFAULT 0, -- 当前库存余量 IsDelete BIT NOT NULL DEFAULT 0 -- 逻辑删除标记,1表示已下架 ); CREATE TABLE Sale ( SaleId INT IDENTITY(1,1) PRIMARY KEY, SaleNo NVARCHAR(50) NOT NULL UNIQUE, -- 销售单号,建议用时间戳生成 SaleTime DATETIME NOT NULL DEFAULT GETDATE(), OperatorId INT NOT NULL, -- 收银员ID,关联员工表 TotalAmount DECIMAL(10,2) NOT NULL -- 整单总金额 ); CREATE TABLE SaleDetail ( DetailId INT IDENTITY(1,1) PRIMARY KEY, SaleId INT NOT NULL FOREIGN KEY REFERENCES Sale(SaleId), ProductId INT NOT NULL, Quantity INT NOT NULL, Price DECIMAL(10,2) NOT NULL, -- 成交单价,下单时从商品表复制 SubTotal AS (Quantity * Price) -- 计算列,行小计不用手动维护 );这段脚本同时演示了三个容易忽略的细节。第一,SaleDetail里存了一个Price字段,它不是冗余,而是订单快照——商品表里的价格以后会涨会降,但 2024 年 3 月你买的这瓶水当时就是 2 块,这个事实不能因为商品表涨价而改变。第二,SubTotal用了计算列,直接用Quantity * Price算出来,省掉一层在 C# 里逐行算钱再赋值的代码。第三,SaleNo建了唯一索引,防止并发收银时生成重复单号。
3.2 库存扣减与事务:用存储过程还是 C# 代码控制
超市系统最容易出 bug 的场景是"同一时间两个收银台卖同一件商品"。界面层拿到库存是 10,两个收银台同时结账,各自判断"10 > 1,可以卖",于是都扣减成功,库存只剩 8,但有两单销售记录——凭空多卖了一件。
解决思路是在库存扣减时做原子操作,不能再"先查询再判断再更新"。常见做法有两条路:一条是写存储过程,用事务包住"检查库存、扣减库存、插入销售明细"三步;另一条是保持代码分层清晰,在 BLL 层用SqlTransaction手动控制事务。考虑到这是学习型项目,建议先看源码用的是哪种。如果源码里是先用 SELECT 查库存在 C# 里判断再 UPDATE,那就属于典型的并发安全隐患,可以拿来作为改造练习点。
事务的边界卡在"哪几步必须同生共死"上。对于一次销售,插入主表、插入明细表、扣减库存表,这三步必须绑成一个事务——任何一步失败,其他两步全部回滚,否则会出现"库存扣了但销售单没生成"这种对不上账的脏数据。而像"更新日志表"这类操作,失败不影响主业务流程,可以不放进同一个事务。
3.3 把数据库文件挂进 SQL Server:附加与常见失败原因
源码包里的数据库文件通常是.mdf加上一个.ldf日志文件,或者是.bak备份文件。.bak恢复最简单:在 SQL Server Management Studio 里右键"数据库"→"还原数据库",选择设备然后指向.bak文件即可。.mdf则需要用"附加"功能。
附加失败多半是两个原因。第一个是文件权限问题:.mdf放在桌面、下载目录这类位置时,SQL Server 服务账号没有读权限,报错信息一般是 "Operating system error 5: 拒绝访问" 之类。解决方法是把.mdf和.ldf一起复制到C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL\\DATA目录下再附加,或者给文件所在目录加上Everyone的完全控制权限。第二个原因是.mdf和.ldf没放在一起,或者.ldf缺失,附加时会报日志文件找不到,可以引导它重建日志文件。
4. 用 C# 把三层架构落进项目:从 Model、DAL、BLL 到界面层
4.1 Model 实体层:让数据库表在 C# 代码里"有型"
Model 层是三层里最不起眼但最重要的层。它的职责是把数据库表结构翻译成 C# 里的类,让上层代码能像操作普通对象一样操作数据。从架构角度讲,Model 层的存在避免了三层之间互相猜字段名的局面——UI 层不会写row["ProductName"].ToString()这种硬编码字符串取值,而是直接拿product.ProductName。
写实体类有几个讲究。第一是属性名尽量和数据库字段名保持一致,省去映射代码;第二是日期时间和字符串字段要注意默认值,比如DateTime在 C# 里是值类型,不能赋null,所以可空日期要声明成DateTime?。下面是商品实体的常见写法:
namespace Supermarket.Model { /// <summary> /// 商品实体类,对应数据库 Product 表 /// </summary> public class Product { public int ProductId { get; set; } public string ProductCode { get; set; } public string ProductName { get; set; } public string Category { get; set; } // 要求两位小数的价格用 decimal,不要用 double 或 float public decimal UnitPrice { get; set; } // 默认库存初始为 0 public int Stock { get; set; } // 逻辑删除标记,查数据时只查 IsDelete == false 的 public bool IsDelete { get; set; } } }价格为什么不用double或float?这是所有初学者必踩的坑。浮点数在计算机里是近似表示的,0.1 加 0.2 在 double 里结果不是精确的 0.3。超市结账天天算钱,用浮点数攒一身误差。decimal是十进制高精度类型,专门为财务计算设计,一套系统下来所有金额字段全得用它。源码里如果出现double price这类写法,可以直接视为缺陷。
4.2 DAL 数据访问层:以商品表为例写增删改查
DAL 层写的是最底层的 SQL 访问逻辑,核心原则是"一个方法对应一个数据操作"。查询、插入、更新、删除、按条件检索各写一个方法,方法只做一件事。好的 DAL 层应该达到这个标准:BLL 层调它时,不用关心 SQL 怎么写、连接怎么开、参数怎么传。
这里以商品表的"按条码查询"和"新增商品"两个方法为例。注意看连接对象的生命周期管理——用using包裹,方法结束自动释放连接,这是防止数据库连接泄漏的关键。很多老项目翻车就翻在这:连接开了不关,跑一个晚上连接池被耗干,数据库直接拒绝新连接。
using System; using System.Data; using System.Data.SqlClient; using Supermarket.Model; namespace Supermarket.DAL { public class ProductDAL { // 连接字符串从配置文件读取,不要硬编码在代码里 private readonly string _connStr = System.Configuration.ConfigurationManager.ConnectionStrings["SupermarketDb"].ConnectionString; /// <summary> /// 根据商品条码查询商品,用于收银台扫码 /// </summary> public Product GetByCode(string productCode) { // using 保证连接用完后自动关闭,即使是异常情况下 using (SqlConnection conn = new SqlConnection(_connStr)) { string sql = @"SELECT ProductId, ProductCode, ProductName, Category, UnitPrice, Stock, IsDelete FROM Product WHERE ProductCode = @code AND IsDelete = 0"; using (SqlCommand cmd = new SqlCommand(sql, conn)) { // 参数化查询,绝不用字符串拼接 SQL cmd.Parameters.AddWithValue("@code", productCode); conn.Open(); using (SqlDataReader reader = cmd.ExecuteReader()) { if (reader.Read()) { return new Product { ProductId = (int)reader["ProductId"], ProductCode = reader["ProductCode"].ToString(), ProductName = reader["ProductName"].ToString(), Category = reader["Category"] is DBNull ? "" : reader["Category"].ToString(), UnitPrice = (decimal)reader["UnitPrice"], Stock = (int)reader["Stock"], IsDelete = (bool)reader["IsDelete"] }; } return null; // 查不到就返回 null,由上层决定提示什么 } } } } /// <summary> /// 新增商品,返回新纪录的自增主键 ID /// </summary> public int Insert(Product product) { using (SqlConnection conn = new SqlConnection(_connStr)) { string sql = @"INSERT INTO Product (ProductCode, ProductName, Category, UnitPrice, Stock, IsDelete) VALUES (@code, @name, @category, @price, @stock, 0); SELECT CAST(SCOPE_IDENTITY() AS INT);"; using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@code", product.ProductCode); cmd.Parameters.AddWithValue("@name", product.ProductName); cmd.Parameters.AddWithValue("@category", string.IsNullOrEmpty(product.Category) ? DBNull.Value : (object)product.Category); cmd.Parameters.AddWithValue("@price", product.UnitPrice); cmd.Parameters.AddWithValue("@stock", product.Stock); conn.Open(); return (int)cmd.ExecuteScalar(); } } } } }这里有两个参数细节值得背下来。第一个是AddWithValue的坑:对于 SQL Server,当传入值是字符串时,AddWithValue会默认推断为 NVARCHAR 类型,如果表字段是 VARCHAR 而且长度不同,可能造成索引失效。严谨的写法是使用cmd.Parameters.Add("@code", SqlDbType.VarChar, 50)再赋值,源码里大量使用AddWithValue的要注意这一层隐患。第二个是SELECT CAST(SCOPE_IDENTITY() AS INT)那个返回值:SCOPE_IDENTITY()返回当前会话、当前作用域最近插入的 IDENTITY 值,用它拿新纪录主键比先查再猜要可靠得多,注意别用那个在并发场景会串值的@@IDENTITY。
4.3 BLL 业务层:库存扣减的校验逻辑该放哪里
BLL 是系统的大脑,它的职责是"规则校验和流程编排"。DAL 层只负责把数据搬进搬出,但"这个商品能不能卖""库存够不够""价格对不对"这些业务规则必须放在 BLL 层。放在 UI 层会让规则散落在各个按钮事件里,改一处忘了另一处,是最典型的翻车起点。
以收银结算为例,这个方法是整个系统最核心的业务逻辑——查商品、校验库存、扣库存、生成销售单,四步必须在一个事务里。看代码里事务的回滚条件,就能看出作者对业务边界理解到什么程度。
using System; using System.Data; using System.Data.SqlClient; using Supermarket.Model; using Supermarket.DAL; namespace Supermarket.BLL { public class SaleBLL { private readonly string _connStr = System.Configuration.ConfigurationManager.ConnectionStrings["SupermarketDb"].ConnectionString; /// <summary> /// 结算:扣库存 + 生成订单 + 生成明细,必须在同一个事务里 /// </summary> /// <param name="items">购物车里的商品集合</param> /// <param name="operatorId">收银员ID</param> /// <returns>生成的销售单号</returns> public string Checkout(List<Product> items, int operatorId) { using (SqlConnection conn = new SqlConnection(_connStr)) { conn.Open(); using (SqlTransaction tx = conn.BeginTransaction()) { try { string saleNo = DateTime.Now.ToString("yyyyMMddHHmmssfff"); decimal totalAmount = 0; // 第一步:插入销售主表 using (SqlCommand cmdSale = new SqlCommand( @"INSERT INTO Sale (SaleNo, OperatorId, TotalAmount) VALUES (@saleNo, @operatorId, @totalAmount); SELECT CAST(SCOPE_IDENTITY() AS INT);", conn, tx)) { cmdSale.Parameters.AddWithValue("@saleNo", saleNo); cmdSale.Parameters.AddWithValue("@operatorId", operatorId); cmdSale.Parameters.AddWithValue("@totalAmount", 0m); int saleId = (int)cmdSale.ExecuteScalar(); // 第二步:逐条插入明细,同时扣减库存 foreach (Product item in items) { // 扣库存必须加上 "库存足够" 的条件,让数据库帮我们兜底 string sqlUpdate = @"UPDATE Product SET Stock = Stock - @qty WHERE ProductId = @pid AND Stock >= @qty"; using (SqlCommand cmdUpdate = new SqlCommand(sqlUpdate, conn, tx)) { cmdUpdate.Parameters.AddWithValue("@qty", item.Quantity); cmdUpdate.Parameters.AddWithValue("@pid", item.ProductId); int affected = cmdUpdate.ExecuteNonQuery(); // 如果影响行数为 0,说明库存不够了,抛异常回滚 if (affected == 0) { throw new Exception($"商品 {item.ProductName} 库存不足"); } } // 插入销售明细 using (SqlCommand cmdDetail = new SqlCommand( @"INSERT INTO SaleDetail (SaleId, ProductId, Quantity, Price) VALUES (@saleId, @pid, @qty, @price);", conn, tx)) { cmdDetail.Parameters.AddWithValue("@saleId", saleId); cmdDetail.Parameters.AddWithValue("@pid", item.ProductId); cmdDetail.Parameters.AddWithValue("@qty", item.Quantity); cmdDetail.Parameters.AddWithValue("@price", item.UnitPrice); cmdDetail.ExecuteNonQuery(); } totalAmount += item.UnitPrice * item.Quantity; } // 第三步:回填主表的总金额 using (SqlCommand cmdTotal = new SqlCommand( @"UPDATE Sale SET TotalAmount = @total WHERE SaleId = @saleId", conn, tx)) { cmdTotal.Parameters.AddWithValue("@total", totalAmount); cmdTotal.Parameters.AddWithValue("@saleId", saleId); cmdTotal.ExecuteNonQuery(); } } tx.Commit(); return saleNo; } catch (Exception ex) { tx.Rollback(); throw new Exception("结算失败,事务已回滚:" + ex.Message, ex); } } } } } }这段代码真正值得学的不是逐行读写,而是那条UPDATE ... WHERE Stock >= @qty的写法。它把"检查库存"和"扣减库存"合并到了同一条 SQL 里,数据库在执行更新时自带行锁,天然避免了并发场景下的超卖问题。如果先SELECT查库存、在 C# 里判断够不够、再UPDATE减库存,三个操作之间会有时间窗,两个收银台就能同时通过检查,把库存减到负数。这就是为什么说"用条件写在 UPDATE 里"比"先查后改"安全得多。
4.4 UI 层与连接字符串:把三层串起来最后一步
UI 层的方法是"三步走":收集界面上用户输入的信息,组装成实体对象或参数列表;调用 BLL 层的方法,把实体传进去;接收返回值,决定是刷新表格还是弹提示。UI 层不写 SQL、不开连接、不判断业务规则,只做"翻译"。
App.config里的连接字符串是整套系统运转的前提。常见做法是放在connectionStrings节点下,Data Source 指向 SQL Server 实例名,Initial Catalog 指向数据库名。这里最容易被坑的是"实例名"——SQL Server Express 版默认实例名带SQLEXPRESS后缀,开发版默认不带;安装时改了实例名的,这里必须跟着改。改错了报错信息会写"建立到服务器的连接时发生错误"或"在建立与服务器的连接期间出错"。
<?xml version="1.0" encoding="utf-8" ?> <configuration> <connectionStrings> <!-- Data Source 填你的 SQL Server 实例名,Initial Catalog 填数据库名 --> <!-- 如果用的是 SQL Server Express,写 localhost\SQLEXPRESS --> <!-- 如果本机装了默认实例,写 . 或 localhost 就行 --> <add name="SupermarketDb" connectionString="Data Source=localhost;Initial Catalog=SupermarketDB;Integrated Security=True;TrustServerCertificate=True" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>Integrated Security=True是 Windows 身份验证,走的是当前登录 Windows 的账号权限。如果换成了 SQL Server 账号密码登录,要改成User ID=sa;Password=你的密码。团队开发时,一份App.config提交到代码仓库里,每个人的连接字符串不同,改来改去容易误提交,可以考虑每台机器维护一份独立的App.config、提交时排除它,这个习惯越早建立越好。
4.5 把源码包跑起来的最小步骤:从解压到看到登录界面
这一步是很多人拿到压缩包后的第一道坎。按我排掉的坑的经验,顺序很重要:
第一步,确认数据库文件。如果是.mdf,确保.ldf也在同一目录,然后打开 SQL Server Management Studio,附加它。附加成功后记下数据库名称,后面的连接字符串要对上。
第二步,打开.sln解决方案文件,右键解决方案→"管理解决方案的 NuGet 程序包"确认引用的依赖包都在。如果你用的 Visual Studio 版本和作者不同,可能要先改一下目标框架版本。
第三步,打开App.config,把连接字符串里的Data Source改成你的实例名。这一步改完先编译一下,跑起来看看到哪一步报错。
第四步,运行登录界面。如果报"无法打开登录所请求的数据库",大概率是连接字符串里数据库名写错了,或者附加时数据库名和Initial Catalog对不上。如果报"该文件没有关联的应用程序",则说明.sln文件没有正确关联到 Visual Studio。
这套顺序的核心原则是"先让数据库就位,再让代码连上数据库"。很多人一上来就编译,报一堆错也不知道从哪下手,其实大部分错误和代码无关,纯粹是环境问题。
5. 避坑指南:跑这套系统最常见的五个坑与国家通用解法
5.1 连接字符串里 Data Source 写错,报"建立连接时出错"
现象:编译通过,点登录按钮直接报"在建立与服务器的连接期间出错"或"初始化字符串的格式不符合规范"。
原因:分两种,第一种是Data Source里的实例名本机不存在;第二种是把Initial Catalog或User ID写错了。热词"数据库同步工具"常有连带问题——如果你本机装了多个数据库版本,实例名会不一样,这个错就更容易出现。
解决:打开 SQL Server Management Studio,登录窗口里"服务器名称"下拉框所列的示例就是你本机真正的实例名,原样抄进Data Source。注意localhost和localhost\\SQLEXPRESS(反斜杠在 XML 里要写成\\\\)是两个完全不同的东西。
5.2 附加数据库时报"无法打开物理文件"或"拒绝访问"
现象:右键"附加",选完.mdf点确定,报操作系统错误 5(拒绝访问),或者提示文件路径无效。
原因:SQL Server 服务进程对.mdf所在目录没有读写权限。.mdf放在桌面、U 盘、压缩包内直接解压出来的目录时最容易触发,因为NETWORK SERVICE账号访问不了这些用户目录。
解决:把整个数据库文件夹复制到C:\\Program Files\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQL\\DATA再附加,或者右键该文件夹→属性→安全,给Everyone添加"完全控制"权限。改完附加成功后,记得把权限收回来或者移动到受控目录,不要长期给Everyone开全权限。
5.3 Model 实体类字段和数据库表列对不上,查询结果全是 null
现象:系统能跑,但商品列表的某些列永远是空的,或者新增数据时报"列名无效"。
原因:源码包里的实体类是用作者本机数据库版本生成的,你附加的数据库文件如果来自别处,结构可能不完全一样。更常见的是,你按上文建库脚本重建了数据库,但实体类里还保留着旧字段名,两边对不上。
解决:打开Product表,展开"列",和Product.cs里的属性逐个对照,把不匹配的改成一致。记住一个原则:数据库表字段名是"原名",实体类属性名是"别名",两者通过映射才能对应。在没有 ORM 框架的情况下,这个对应完全靠手写代码维持,审查的时候要逐字对照。
5.4 收银时中文商品名变成 ?????,或者控制台输出乱码
现象:中文字段写入数据库后变成一串问号,查询出来也是问号。这个坑在数据库课程设计里出现频率极高。
原因:建表时字段类型用了VARCHAR,它只支持 ASCII 字符集,中文存不进去。正确类型应该是NVARCHAR,前缀 N 代表 Unicode 编码,中文、日文、emoji 都能存。另一个次要原因是连接字符串里没有设置Character Set参数(MySQL 场景更典型,SQL Server 场景主要是字段类型问题)。
解决:把表结构里所有中文相关字段从VARCHAR改成NVARCHAR。如果已经存进去一堆问号,数据救不回来了,只能删掉重录。这是一个典型的"建表时省事,后面交学费"的坑,所以前面特意在 3.1 的建表脚本里全用了NVARCHAR。
5.5 改了数据库表结构,程序运行还是旧结构,报错"对象名无效"
现象:你给Product表加了一个Remark字段,程序一跑就报"列名 'Remark' 无效"。
原因:程序可能连接的是另一个数据库。最常见的情况是有两个同名库,一个在 Visual Studio 自带的 LocalDB 里,一个在 SQL Server 里,连接字符串指向 LocalDB,你却改了 SQL Server 里的表。
解决:在App.config的Data Source和Initial Catalog里核对到底连的哪个库。另外一个更容易忽略的点:如果你在 Visual Studio 里右键"添加→服务引用"或者用了数据集设计器,它会生成一份强类型的DataSet缓存,表结构变了缓存不会自动刷新。右键设计器文件→"运行自定义工具",或者删除重新生成一次。这个坑属于"黑匣子"级别,排查了半天,结果问题出在缓存。
6. 从能跑改成好用:三个值得动手的改造方向
拿到这套源码能跑起来只是第一步,真正值钱的是你在这套代码上做的改造和思考。按投入产出比排序,我建议从这三个方向里挑一个动手。
第一个是给销售模块加"当日销售汇总报表"。这需要你新增一个报表查询页面,在 DAL 层写一个按日期分组的统计查询,在 BLL 层做日期范围校验(开始日期不能晚于结束日期),映射到 UI 层展示。这个改动不大,但把三层架构的完整链路又走了一遍,做完能对分层有更深的体感。顺手把金额统计用decimal算清楚,你会发现自己已经在用生产级的规范写代码了。
第二个是改造登录模块,加入 MD5 或者 SHA256 密码哈希存储。查一下用户表里的密码字段,大概率是明文存储。这个改动会牵动注册、登录、修改密码三处逻辑,正是练习"改一层会不会影响另一层"的好机会——如果你改登录逻辑时发现 SQL 写在按钮事件里,恭喜,你已经知道什么代码不健康了。
第三个,也是最推荐的:给库存扣减加"定时对账"。写一个后台任务(比如每天打烊后运行),统计当天销售明细的扣减总量,和Product表实际库存变化量做差,有差值就把异常数据挑出来。这个功能在真实超市系统里叫"日结",是运营每天必看的报表。做这个改造你会用到日期范围查询、聚合函数、LEFT JOIN 关联,还能自然理解为什么订单明细要按ProductId建索引。
我自己的习惯是:每次跑通一套新的源码,先不急着改功能,而是把连接字符串改成配置文件、把所有AddWithValue改成严格类型化参数、把Form1.cs里的 SQL 全部清进 DAL。做完这三件事,再回头看业务需求就有了底气——因为你已经知道设计这套代码的人什么时候清醒、什么时候在糊弄。希望这篇整理能帮你在三层架构这条路上少走几次弯路,跑得比预期顺利。
本文还有配套的精品资源,点击获取