简介:一份面向使用C#进行Windows窗体开发的学习者的实验器材管理系统完整源码包,基于经典三层架构(UI/BLL/DAL)与Models实体层,搭配SQL Server数据库实现。系统覆盖管理员登录注册、密码修改、器材信息增删改查及借还登记等核心流程,查询模块支持按编号精确匹配、器材名模糊检索、按种类过滤和库存排序,适合作为课程设计或毕业设计的参考原型。压缩包共135个文件,以C#源码(.cs)、项目配置(.csproj)、编译依赖(.dll)、窗体资源(.resx/.resources)及可执行文件(.exe)为主,附带说明文档与数据模型定义,整体仅约916KB,结构清晰易于导入Visual Studio后直接调试。已有131人学习或下载。借助三层框架的分层示例、完整数据操作代码和配套演示视频,可快速理解业务逻辑与数据访问的分离方式,并在现有功能上继续扩展库存预警、器材分类维护等模块。
1. 把它当练手样板:C# Windows 三层架构实验器材管理系统里有什么
一个 C# Windows 三层架构的实验器材管理系统,最值得看的地方不是界面,而是登录注册、器材增删改查、借还登记这些业务,从 SQL Server 数据库到界面到底怎么串起来。这套资源把 UI(BSM)、BLL、DAL、Models 拆成四个独立工程,数据和流程都能直接改,是典型的 C/S 三层架构样板。适合刚学完 C# 语法、想看看真实项目长什么样的学生,也适合需要一套能改的管理系统底子、不想从零搭架构的新手工程师。下文按三层怎么分工、用户模块、器材管理、借还事务、避坑、验证技巧的顺序拆,每一步都放可抄的代码和参数说明。
2. 三层架构分工与用户模块:Models、DAL、BLL 怎么协作
三层架构的核心约束一句话就能讲完:界面层不写 SQL,数据访问层不弹对话框,业务逻辑层做校验和编排。这套系统的四个工程把这条约束落实得很清楚,我拿到项目第一件事就是看 csproj 列表,先把层分出来,再顺着调用链把每个方法归位。
2.1 从 csproj 文件还原项目结构:四个工程各干一件事
文件列表里能看到 BSM.csproj、BSM.BLL.csproj、BSM.DAL.csproj、BSM.Models.csproj 四个工程文件,依赖关系是单向的,我用一张表直接说清:
| 工程 | 层 | 职责 | 允许引用 |
|---|---|---|---|
| BSM.csproj | UI 层 | WinForms 窗体、登录注册界面、列表绑定、弹窗提示 | BLL、Models |
| BSM.BLL.csproj | 业务逻辑层 | 数据校验、业务流程编排、组合 DAL 结果 | DAL、Models |
| BSM.DAL.csproj | 数据访问层 | 连接数据库、执行 SQL、返回实体或集合 | Models |
| BSM.Models.csproj | 模型层 | 实体类,只存数据不带逻辑 | 无 |
常见误用是 UI 里直接 new SqlConnection,或者 DAL 里弹 MessageBox。这两种写法在课设里出现得很多,但一旦界面换掉或者数据库换掉,改动会牵一发动全身。照着这份资源的工程结构做,每个类只干一类事,后面加功能会舒服很多。
2.2 Models 层:把用户、器材写成实体类
Models 层最简单,也最容易被跳过。实体类字段和表列名尽量保持一致,后续 SqlDataReader 按列名取值时不用做映射。用户和器材两个实体是这样:
namespace BSM.Models { public class User { public int Id { get; set; } public string UserName { get; set; } public string Password { get; set; } public DateTime CreateTime { get; set; } } public class Equipment { public int Id { get; set; } public string EquipmentNo { get; set; } // 器材编号 public string EquipmentName { get; set; } // 器材名称 public string Category { get; set; } // 器材种类 public int Stock { get; set; } // 库存 public string Location { get; set; } // 存放位置 } }参数说明:Id 对应数据库自增主键;EquipmentNo 是界面上的业务主键,删除、查询、借还都以它为准。Password 用 string 明文放着的确不好,但这是练手项目的常态,生产环境至少要做哈希加盐,这一点后面避坑章再展开。
2.3 DAL 层:用户注册和登录只有三条 SQL
DAL 层每张表对应一个类,每个方法对应一种数据操作。用户这块需要注册(INSERT)、按用户名查(SELECT)、改密码(UPDATE)。给出注册和查询的完整写法,注意 DAL 项目要引用 System.Configuration:
namespace BSM.DAL { public class UserDAL { private readonly string connStr = ConfigurationManager.ConnectionStrings["BSMConn"].ConnectionString; public int Insert(User user) { string sql = "INSERT INTO Users(UserName, Password, CreateTime) " + "VALUES(@UserName, @Password, GETDATE())"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@UserName", user.UserName); cmd.Parameters.AddWithValue("@Password", user.Password); conn.Open(); return cmd.ExecuteNonQuery(); } } public User GetByUserName(string userName) { string sql = "SELECT Id, UserName, Password, CreateTime FROM Users " + "WHERE UserName = @UserName"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@UserName", userName); conn.Open(); using (SqlDataReader reader = cmd.ExecuteReader()) { if (reader.Read()) { return new User { Id = (int)reader["Id"], UserName = reader["UserName"].ToString(), Password = reader["Password"].ToString(), CreateTime = Convert.ToDateTime(reader["CreateTime"]) }; } } } return null; } } }几个参数要点:所有值都走 SqlParameter,不拼字符串,避免单引号注入和中文转义问题;ExecuteNonQuery 返回受影响行数,注册成功会返回 1;ExecuteReader 用 using 包住,数据读完连接能及时归还连接池。UpdatePassword 就是一条UPDATE Users SET Password=@NewPwd WHERE UserName=@UserName,写法同上,不再重复贴。
2.4 BLL 层:把“查一下”变成“能不能登录”
BLL 层存在的意义是把 DAL 的单个动作组合成业务规则。同样是"查用户",DAL 只回答“这个用户存不存在”,BLL 才判断“密码对不对、能不能登录”。注册时还要先查重、再插入:
namespace BSM.BLL { public class UserManager { private readonly UserDAL dal = new UserDAL(); public bool Register(User user) { if (string.IsNullOrWhiteSpace(user.UserName) || string.IsNullOrWhiteSpace(user.Password)) { throw new ArgumentException("用户名和密码不能为空"); } if (dal.GetByUserName(user.UserName) != null) { throw new InvalidOperationException("该用户名已存在"); } return dal.Insert(user) > 0; } public bool Login(string userName, string password) { User user = dal.GetByUserName(userName); return user != null && user.Password == password; } public bool ChangePassword(string userName, string oldPwd, string newPwd) { User user = dal.GetByUserName(userName); if (user == null) throw new InvalidOperationException("用户不存在"); if (user.Password != oldPwd) throw new InvalidOperationException("原密码错误"); return dal.UpdatePassword(userName, newPwd) > 0; } } }这样 UI 层调用 Login 时,不需要知道背后是 SELECT 还是存储过程。以后想改成“连续输错五次锁定账号”,也只需要在 BLL 里加计数逻辑,界面和数据库都不用动。这就是把业务规则放在中间层而不是界面的好处。
3. 器材信息管理:增删改查与三种查询方式的实现
器材信息模块是这套系统的重头戏,功能列表里八项,五项落在器材上。建表时把器材种类和库存单拎出来,是因为后面查询和排序会直接用到这两个字段。
3.1 器材表结构:编号做主键还是自增主键
器材表在 SQL Server 里我会这样建:
CREATE TABLE Equipment ( Id INT IDENTITY(1,1) PRIMARY KEY, EquipmentNo NVARCHAR(50) NOT NULL UNIQUE, EquipmentName NVARCHAR(100) NOT NULL, Category NVARCHAR(50) NOT NULL, Stock INT NOT NULL DEFAULT 0, Location NVARCHAR(100) NULL );Id 是物理主键,EquipmentNo 是业务主键。界面上的添加、删除、查询、借还全部以 EquipmentNo 为准,这样即使将来合并数据,编号不会和自增 Id 冲突。Stock 默认 0 给“新器材还没入库先建档案”的场景兜底。添加器材就是 INSERT,删除就是DELETE FROM Equipment WHERE EquipmentNo = @No,两条语句都是参数化写法,和用户模块的 Insert 基本一致,区别只在表名和字段:
INSERT INTO Equipment(EquipmentNo, EquipmentName, Category, Stock, Location) VALUES(@No, @Name, @Category, @Stock, @Location);参数取值范围和 2.3 节一样,字符串全部走 SqlParameter。特别注意 EquipmentNo 是从界面文本框读进来的编号,别拿用户输入直接拼 SQL,否则器材编号里出现单引号直接炸。
3.2 编号精确查询与名称模糊查询:LIKE 参数的正确拼法
编号精确查询最简单,WHERE EquipmentNo = @No,返回单个实体还是 null 由调用方判断。名称模糊查询是这套系统里第一个容易翻车的点:
public List<Equipment> SearchByName(string name) { string sql = "SELECT * FROM Equipment WHERE EquipmentName LIKE @Name"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { // % 号加在参数值里,不是加在 SQL 字符串里 cmd.Parameters.AddWithValue("@Name", "%" + name.Trim() + "%"); conn.Open(); List<Equipment> list = new List<Equipment>(); using (SqlDataReader reader = cmd.ExecuteReader()) { while (reader.Read()) { list.Add(new Equipment { Id = (int)reader["Id"], EquipmentNo = reader["EquipmentNo"].ToString(), EquipmentName = reader["EquipmentName"].ToString(), Category = reader["Category"].ToString(), Stock = (int)reader["Stock"], Location = reader["Location"] == DBNull.Value ? "" : reader["Location"].ToString() }); } } return list; } }重点说明:写成WHERE EquipmentName LIKE '%@Name%'是错的,SQL Server 会把 @Name 当成字面量的一部分,结果一条都查不到。正确做法是把 % 拼进参数值,SQL 里只保留 @Name 占位符。另外记得对输入做 Trim(),很多人复制粘贴的时候会带上空格,导致明明有数据却查不到。
3.3 按种类查询与库存排序:ORDER BY 只能走白名单
按种类查询是精确匹配,WHERE Category = @Category。库存排序显示这个需求,排序字段不能参数化,只能拼 SQL,所以要做白名单判断:
public List<Equipment> SearchByCategory(string category, string stockOrder) { string sql = "SELECT * FROM Equipment WHERE Category = @Category"; if (stockOrder == "asc") sql += " ORDER BY Stock ASC"; else if (stockOrder == "desc") sql += " ORDER BY Stock DESC"; // 其余情况不排序 using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@Category", category); // 执行并读取,逻辑同 SearchByName,这里省略 } }ORDER BY 直接拼接确实有注入风险,所以只允许传入 asc 和 desc 两个固定值,界面下拉框也只给这两个选项。如果你想把排序字段也做成参数,SQL Server 侧要用存储过程和 CASE WHEN 映射,练手项目没必要上,白名单够用。
3.4 修改器材信息:别再写一条 SQL 把没填的字段覆盖掉
修改器材信息最常见的错误写法:把界面上所有文本框都丢进 UPDATE,没填的字段变成了空字符串。我一般用“先查后改”:先把原记录查出来回填到界面,用户改了哪项,提交时就覆盖哪项。
public bool Update(Equipment eq) { string sql = "UPDATE Equipment SET EquipmentName=@Name, Category=@Category, " + "Stock=@Stock, Location=@Location WHERE EquipmentNo=@No"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@Name", eq.EquipmentName); cmd.Parameters.AddWithValue("@Category", eq.Category); cmd.Parameters.AddWithValue("@Stock", eq.Stock); cmd.Parameters.AddWithValue("@Location", eq.Location); cmd.Parameters.AddWithValue("@No", eq.EquipmentNo); conn.Open(); return cmd.ExecuteNonQuery() > 0; } }界面修改逻辑对应:先按编号查出来,再把查询对象里的属性逐个赋值为界面文本框的值,最后调 Update。这样没有填的字段会保留原值,而不是被 NULL 覆盖。这条经验在维护别人的系统时特别有用,改器材信息比删除器材信息更容易踩坑,因为问题隐蔽,用户往往要过几天才发现数据不对。
4. 借还与库存更新:为什么要用 SqlTransaction
借还是这套系统里唯一一个“一次操作要同时写两张表”的功能。借出要扣器材库存、写借还登记;归还要更新登记状态、回补器材库存。多个 SQL 在一个操作里完成,就必须考虑事务。
4.1 借出流程:把库存判断写进 UPDATE 的 WHERE
借出的业务目标只有两件事:把器材库存扣掉、登记一条借出记录。库存够不够不必单独 SELECT 去查,把判断条件直接写进 UPDATE 的 WHERE 就行:
string updateSql = "UPDATE Equipment SET Stock = Stock - @Qty " + "WHERE EquipmentNo = @No AND Stock >= @Qty";这样数据库在扣减的同时判断库存够不够,受影响行数为 0 就说明库存不足,直接回滚。常见翻车写法是先 SELECT 查库存,C# 里 if 判断够不够,再 UPDATE 扣减。这个写法单用户没事,多用户同时借同一台示波器时,两次 SELECT 都读到库存为 2,然后都执行扣减,库存就变成 0 而不是应该的 1。把并发问题交给数据库解决,比在 C# 代码里加锁简单可靠。
4.2 归还流程:先更新登记,再回补库存
归还时先按记录 Id 把那条未归还的记录改成已归还,再回补器材库存。顺序不能反:如果先回补库存,登记更新失败,账面上库存多了、借还记录还是未还,数据就对不上了。归还登记表结构可以这样设计:
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | INT IDENTITY | 主键 |
| EquipmentNo | NVARCHAR(50) | 对应器材编号 |
| Borrower | NVARCHAR(50) | 借用人 |
| Qty | INT | 借出数量 |
| BorrowDate | DATETIME | 借出时间 |
| ReturnDate | DATETIME NULL | 归还时间,NULL 表示未还 |
| Status | INT | 0 未归还,1 已归还 |
归还的 SQL 顺序:先UPDATE BorrowRecord SET Status=1, ReturnDate=GETDATE() WHERE Id=@RecordId AND Status=0,再UPDATE Equipment SET Stock = Stock + @Qty WHERE EquipmentNo = @No。
4.3 事务写法:把三条 SQL 包进同一个 SqlTransaction
把借出流程写成完整代码,注意 SqlCommand 复用时的参数处理:
public void Borrow(string equipmentNo, string borrower, int qty) { using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); SqlTransaction tran = conn.BeginTransaction(); using (SqlCommand cmd = new SqlCommand()) { cmd.Connection = conn; cmd.Transaction = tran; try { cmd.CommandText = "UPDATE Equipment SET Stock = Stock - @Qty " + "WHERE EquipmentNo = @No AND Stock >= @Qty"; cmd.Parameters.AddWithValue("@Qty", qty); cmd.Parameters.AddWithValue("@No", equipmentNo); int affected = cmd.ExecuteNonQuery(); if (affected == 0) throw new InvalidOperationException("库存不足,借出失败"); cmd.CommandText = "INSERT INTO BorrowRecord(EquipmentNo, Borrower, Qty, BorrowDate, Status) " + "VALUES(@No, @Borrower, @Qty, GETDATE(), 0)"; cmd.Parameters.Clear(); // 关键:换 SQL 前清掉旧参数 cmd.Parameters.AddWithValue("@No", equipmentNo); cmd.Parameters.AddWithValue("@Borrower", borrower); cmd.Parameters.AddWithValue("@Qty", qty); cmd.ExecuteNonQuery(); tran.Commit(); } catch { tran.Rollback(); throw; } } } }两个容易忽略的细节:一是复用 SqlCommand 改 CommandText 时,必须 Parameters.Clear(),否则会残留 @Qty、@No 这些上一轮参数,甚至报“变量名 @Qty 已被声明”的错误;二是事务里任何一步抛异常,Rollback 会把扣库存的操作一起撤销,保证借还记录和库存永远一致。这套手法的适用范围不只是借还,凡是“一次点击改两张表”的场景,比如器材入库同时更新供应商记录,都应该照这样包事务。
5. 避坑与常见问题:跑这套系统必须绕开的五个坑
这个项目是个练手样板,但数据库连接、项目引用、SQL 写法这些环节,每个都是新手容易卡住的地方。下面按出现频率排序记录五条,都是我实际遇到过、也看别人反复踩的。
5.1 数据库附加不上,多半是版本对不上
现象:双击项目里的 .mdf 附加到 Visual Studio 时报错,提示“数据库 'BSM' 的版本高于此实例所支持的版本”,或者“文件正由另一进程使用”。
原因:本机 Visual Studio 内置 LocalDB 的版本低于建库时的 SQL Server 版本,或者上一次调试进程没有释放数据库文件。
解决:先用 SQL Server Management Studio 附加数据库,确认版本;附加时若报“文件正被占用”,打开任务管理器结束残留的 sqlservr.exe 子进程。连接字符串优先用Data Source=.\SQLEXPRESS;Initial Catalog=BSM;Integrated Security=True,如果本机只装了 LocalDB 就改用(localdb)\MSSQLLocalDB。资源里带了演示视频,对照视频里的实例名直接抄最稳。
5.2 中文模糊查询查不到记录
现象:器材名称里明明有“示波器”,输入“示波”查出来是空。
原因:最常见两种情况,一是表字段建成了 VARCHAR 而不是 NVARCHAR,中文被转成问号存进去;二是输入法输入时带了不可见字符,Trim() 没做干净。
解决:建表时字符串字段用 NVARCHAR,这是治本的。已经建好的表执行一条 ALTER TABLE 把字段类型改掉,已经乱码的数据只能清掉重录,没救。查询代码里对关键字先Trim(),如果还查不到,把最终执行的 SQL 和参数值打印出来看一眼,直接定位是编码问题还是空格问题。
5.3 编译报错:UI 里找不到 BLL 的类型
现象:窗体代码里写下UserManager userManager = new UserManager();,编译器报“未能找到类型或命名空间 UserManager”。
原因:BSM 主工程没有添加对 BLL 工程的引用,或者 BLL 没有引用 DAL,导致类型不可见。
解决:右键 BSM 工程 → 添加 → 项目引用,勾选 BSM.BLL 和 BSM.Models。记住引用方向是单向的:BSM → BLL → DAL → Models。如果出现 DAL 引用 UI、BLL 引用 BSM 这种环形引用,Visual Studio 会直接拒绝,把多余的引用删掉重加就行。
5.4 借出后库存变成负数
现象:库存显示 1,连续借出三次,界面变成 -2。
原因:借出逻辑只写了 SELECT 查库存,C# 判断够了就 UPDATE 扣减。两个窗口同时操作时,后一个判断读到的是旧值,扣减就超了。
解决:把判断塞进 UPDATE 的 WHERE 条件,写成UPDATE Equipment SET Stock = Stock - @Qty WHERE EquipmentNo = @No AND Stock >= @Qty,受影响行数为 0 就提示库存不足并回滚。这个写法由数据库自己保证原子性,比任何 C# 里的 if 判断都可靠。
5.5 DataGridView 操作后不刷新
现象:新增、修改、删除器材后,界面列表还是旧数据,重启程序才能看到最新结果。
原因:DataGridView 绑定的 DataTable 还是第一次查询时的实例,数据源对象没变,界面自然不会重绘。
解决:每次业务操作成功后,重新执行查询并重新给 DataSource 赋值,赋值前先置 null。这是解决列头和内容不同步的常用手段:
dataGridView1.DataSource = null; dataGridView1.DataSource = manager.SearchByName(txtKey.Text.Trim());加上那句 null 之后,列头不会出现新增列挤在一起的错位问题。如果列表还带序号列,也要在重新绑定后重绘一下行号。
6. 一个值得保留的验证技巧:把 BLL 层当黑匣子逐个打点
界面难调试,是因为问题会发生在“按钮事件 → BLL → DAL → 数据库”的任何一环,还经常伴随界面线程的干扰。我的做法是先不开窗体,直接建一个临时控制台项目,引用 BLL 和 Models,把核心方法按业务顺序打一遍:
static void Main(string[] args) { var userManager = new UserManager(); var reg = userManager.Register(new User { UserName = "tester", Password = "123456" }); Console.WriteLine("注册: " + reg); Console.WriteLine("登录: " + userManager.Login("tester", "123456")); var eqManager = new EquipmentManager(); var eq = new Equipment { EquipmentNo = "EQ001", EquipmentName = "示波器", Category = "仪器", Stock = 5, Location = "A01柜" }; Console.WriteLine("新增器材: " + eqManager.AddEquipment(eq)); Console.WriteLine("借出: " + eqManager.Borrow("EQ001", "张三", 2)); // 先查到未归还记录的 Id,再调用归还,这里用 1 占位 Console.WriteLine("归还: " + eqManager.ReturnEquipment(1, "EQ001", 2)); }为什么这么干?三层架构中 BLL 的公共方法是稳定契约,控制台项目里每个方法返回是否符合预期,一眼就能看到。如果控制台里注册、登录、借出、归还全部正常,那问题就锁定在 WinForms 事件或绑定逻辑上;如果控制台里就抛异常,多半是数据库连接、SQL、事务这三件事之一,按避坑章的顺序排查。
我还习惯补几组边界测试:库存为 0 时借出、注册重复用户名、改密码时原密码错误。这些场景在界面上逐个点要开好几个窗口,控制台里只需要两三行代码,能直接确认异常是否抛得准确。
从那以后我每次拿到别人的 C# 三层项目,都会先建这个临时控制台,把 BLL 的关键接口打一遍,确认核心逻辑通顺再开界面,省掉大约一半的断点调试时间。这份实验器材管理系统也建议你按这个顺序跑一遍,先控制台、后界面、再看演示视频对照,希望帮到你。
本文还有配套的精品资源,点击获取