简介:这份资源是面向计算机相关专业在校学生与教师的 C# 图书管理系统课程设计完整方案,基于 .Net Framework WinForm 与 MySQL 8.0.21 开发,适合作为期末大作业、课程设计或入门进阶练习。功能覆盖用户登录、图书入库与维护、权限管理、借书续借还书、修改密码等模块,并采用操作缓存机制——所有增删改需手动提交才写入数据库,可随时放弃,便于理解事务与数据一致性思路。压缩包共 79 个文件,约 8.12MB,以 47 个 cs 源码、12 个 resx 窗体资源、6 个 config 配置及 csproj 工程文件为主,另含 sql 建库脚本、sln 解决方案与项目说明文档,分层结构清晰,涵盖 DAL、BLL、Model 与 UI 各层。已有 189 人学习下载,可直接运行验证,也便于在此基础上进行二次开发与功能拓展。
1. 从一份 sln 解决方案说起:C# + MySQL 图书管理系统到底能跑出什么
很多人第一次拿到「C#基于Mysql的简易图书管理系统+sql数据库+项目说明+sln解决方案.zip」这类资源时,第一反应是双击 .sln 然后 F5,结果要么是 MySQL 连接字符串报错,要么是 DataGridView 一片空白。这个标题背后其实是一套非常典型的桌面端 CRUD 工程:C# WinForms 做界面,MySQL 存数据,sln 解决方案把 UI、业务逻辑、数据访问拆成若干项目。它解决的核心问题是——让一个刚学完 C# 基础、知道c#数组和集合区别、但没做过完整项目的人,能在一个下午内跑通「登录 → 图书增删改查 → 借阅归还」这条主链路。适合谁?适合课程设计、毕业设计起步、或者想用c#上位机思路练手数据库交互的开发者。别把它当玩具,它麻雀虽小,但连接池、事务、参数化查询这些坑一个不少。
2. 拆开 sln 解决方案:三层结构怎么分、MySQL 表怎么建
2.1 先看清 sln 里到底有几个项目
一份规范的简易图书管理系统 sln,通常包含三个项目:BookManagement.UI(WinForms 窗体)、BookManagement.BLL(业务逻辑)、BookManagement.DAL(数据访问)。有些版本会偷懒只放一个项目,所有代码堆在 Form1.cs 里,这种跑起来快但改起来痛。我一般会先看解决方案资源管理器里的引用关系:UI 引用 BLL,BLL 引用 DAL,DAL 引用MySql.Data或MySqlConnector。如果引用是反的,或者 UI 直接引用 DAL,说明分层没做干净,后面加功能会翻车。
判断依据很简单:打开.csproj文件,看<PackageReference>或<Reference>节点。如果是老式 .NET Framework 项目,会在packages.config里看到MySql.Data的版本号。这里有个血泪经验——MySql.Data8.0 以上和 .NET Framework 4.5 混用容易出Authentication method 'caching_sha2_password' not supported的错,因为 MySQL 8 默认用 caching_sha2_password 插件,老驱动不认。解决办法要么升级驱动,要么在 MySQL 里把用户改成mysql_native_password。
2.2 数据库表设计:四张表撑起主链路
图书管理系统的核心表不多,但字段类型和约束决定了后面代码好不好写。下面是我从常见项目里整理出的最小可用表结构,直接可以在 MySQL Workbench 里执行:
-- 图书表:存书的基本信息和库存 CREATE TABLE books ( book_id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(100) NOT NULL, author VARCHAR(50) DEFAULT '未知', publisher VARCHAR(50) DEFAULT '未知', total_copies INT NOT NULL DEFAULT 1, available_copies INT NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 读者表:借书的人 CREATE TABLE readers ( reader_id INT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(30) NOT NULL, phone VARCHAR(15), status TINYINT DEFAULT 1 -- 1正常 0冻结 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 借阅记录表:谁借了哪本书、什么时候还 CREATE TABLE borrow_records ( record_id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL, return_date DATE DEFAULT NULL, FOREIGN KEY (book_id) REFERENCES books(book_id), FOREIGN KEY (reader_id) REFERENCES readers(reader_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 用户表:系统登录用 CREATE TABLE users ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password_hash VARCHAR(64) NOT NULL, role VARCHAR(10) DEFAULT 'admin' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:books表用total_copies和available_copies两个字段分开记总库存和可借数,比只记一个库存字段更稳,因为借出时只减available_copies,归还时加回来,不会把总库存改乱。borrow_records用外键约束保证不会出现借一本不存在的书。users表存password_hash而不是明文,哪怕简易系统也别留这个隐患。
参数说明:utf8mb4是为了支持中文书名和特殊符号;InnoDB引擎支持事务,借书时「减库存 + 插记录」要放在一个事务里,否则可能减了库存但记录没插进去。AUTO_INCREMENT主键让 C# 端插入后能用LAST_INSERT_ID()拿到新 ID。
2.3 连接字符串怎么配才不报错
C# 连 MySQL 的连接字符串常见写法:
// App.config 或代码里直接拼 string connStr = "Server=localhost;Port=3306;Database=library_db;" + "Uid=root;Pwd=你的密码;CharSet=utf8mb4;SslMode=None;";SslMode=None在本地开发时能避免证书报错,但生产环境别这么干。CharSet=utf8mb4要和建表时一致,否则中文书名会变问号。如果 MySQL 装在另一台机器,把localhost换成 IP,同时确认防火墙放行 3306 端口。常见翻车点:密码里带;或#,直接拼字符串会截断,要用MySqlConnectionStringBuilder来构造。
3. 用 C# 把增删改查写进 DAL:参数化查询与事务的落地写法
3.1 为什么必须用参数化查询而不是拼字符串
新手最容易犯的错是"SELECT * FROM books WHERE title = '" + txtTitle.Text + "'",这不仅有 SQL 注入风险,还会因为书名里带单引号直接报语法错误。正确做法是用MySqlParameter:
public List<Book> SearchBooks(string keyword) { var list = new List<Book>(); string sql = "SELECT book_id, isbn, title, author, available_copies " + "FROM books WHERE title LIKE @kw OR author LIKE @kw"; using (var conn = new MySqlConnection(connStr)) using (var cmd = new MySqlCommand(sql, conn)) { // 参数化:% 在值里加,不在 SQL 里拼 cmd.Parameters.AddWithValue("@kw", "%" + keyword + "%"); conn.Open(); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { list.Add(new Book { BookId = reader.GetInt32("book_id"), Isbn = reader.GetString("isbn"), Title = reader.GetString("title"), Author = reader.GetString("author"), AvailableCopies = reader.GetInt32("available_copies") }); } } } return list; }逻辑说明:using保证连接和命令对象用完就释放,避免连接池被占满。AddWithValue把%keyword%作为参数值传入,MySQL 会把它当纯文本处理,单引号、百分号都不会破坏 SQL 结构。reader.GetInt32("book_id")按列名取值比按索引取值更抗字段顺序变化。
参数说明:LIKE @kw里的@kw是占位符,实际值由AddWithValue提供。如果搜索关键词为空,%%会匹配所有记录,所以业务层要先判断空字符串直接返回空列表或全量列表,别让数据库白跑。
3.2 借书操作:一个事务里完成减库存和插记录
借书不是单条 SQL 能搞定的,它要同时做两件事:把available_copies减 1,往borrow_records插一条记录。如果减完库存程序崩了,记录没插进去,库存就白白少了一本。所以必须用事务:
public bool BorrowBook(int bookId, int readerId, DateTime dueDate) { using (var conn = new MySqlConnection(connStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 第一步:检查库存并锁定行,防止并发借同一本书 string checkSql = "SELECT available_copies FROM books " + "WHERE book_id = @bid FOR UPDATE"; int available; using (var checkCmd = new MySqlCommand(checkSql, conn, tran)) { checkCmd.Parameters.AddWithValue("@bid", bookId); available = Convert.ToInt32(checkCmd.ExecuteScalar()); } if (available <= 0) { tran.Rollback(); return false; // 库存不足 } // 第二步:减库存 string updateSql = "UPDATE books SET available_copies = available_copies - 1 " + "WHERE book_id = @bid"; using (var updateCmd = new MySqlCommand(updateSql, conn, tran)) { updateCmd.Parameters.AddWithValue("@bid", bookId); updateCmd.ExecuteNonQuery(); } // 第三步:插借阅记录 string insertSql = "INSERT INTO borrow_records " + "(book_id, reader_id, borrow_date, due_date) " + "VALUES (@bid, @rid, CURDATE(), @due)"; using (var insertCmd = new MySqlCommand(insertSql, conn, tran)) { insertCmd.Parameters.AddWithValue("@bid", bookId); insertCmd.Parameters.AddWithValue("@rid", readerId); insertCmd.Parameters.AddWithValue("@due", dueDate); insertCmd.ExecuteNonQuery(); } tran.Commit(); return true; } catch (Exception ex) { tran.Rollback(); // 实际项目里记日志,别吞异常 throw new Exception("借书失败:" + ex.Message); } } } }逻辑说明:FOR UPDATE在事务里锁住那一行,防止两个管理员同时借最后一本书导致库存变负数。三步操作全在同一个MySqlTransaction里,任何一步抛异常都Rollback,保证数据一致。CURDATE()用数据库时间而不是 C# 本地时间,避免客户端时区不一致。
参数说明:dueDate由 UI 层传入,一般是借书日期加 30 天。ExecuteScalar()返回第一行第一列,适合查单个值。ExecuteNonQuery()返回受影响行数,可以用来判断 UPDATE 是否真的改到了记录。
3.3 归还操作与逾期判断
归还比借书简单,但要注意把return_date填上,同时库存加回来:
public string ReturnBook(int recordId) { using (var conn = new MySqlConnection(connStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { // 查出这条记录对应的 book_id 和 due_date string querySql = "SELECT book_id, due_date FROM borrow_records " + "WHERE record_id = @rid AND return_date IS NULL"; int bookId; DateTime dueDate; using (var queryCmd = new MySqlCommand(querySql, conn, tran)) { queryCmd.Parameters.AddWithValue("@rid", recordId); using (var reader = queryCmd.ExecuteReader()) { if (!reader.Read()) return "记录不存在或已归还"; bookId = reader.GetInt32("book_id"); dueDate = reader.GetDateTime("due_date"); } } // 更新归还日期 string updateRecord = "UPDATE borrow_records SET return_date = CURDATE() " + "WHERE record_id = @rid"; using (var cmd1 = new MySqlCommand(updateRecord, conn, tran)) { cmd1.Parameters.AddWithValue("@rid", recordId); cmd1.ExecuteNonQuery(); } // 库存加一 string updateStock = "UPDATE books SET available_copies = available_copies + 1 " + "WHERE book_id = @bid"; using (var cmd2 = new MySqlCommand(updateStock, conn, tran)) { cmd2.Parameters.AddWithValue("@bid", bookId); cmd2.ExecuteNonQuery(); } tran.Commit(); // 逾期判断:还书日期超过应还日期就算逾期 int overdueDays = (DateTime.Today - dueDate).Days; return overdueDays > 0 ? $"已归还,逾期 {overdueDays} 天" : "已归还"; } } }逻辑说明:先查记录确认没还过,再更新归还日期和库存。return_date IS NULL条件防止重复归还同一本书导致库存虚增。逾期天数在 C# 端算,不依赖数据库函数,方便以后换数据库。
参数说明:recordId是借阅记录的主键,不是图书 ID。UI 层传参时别搞混,否则会还错书。
4. 避坑与排查:连接、编码、并发这三类问题最耗时间
4.1 现象:程序启动就报Unable to connect to any of the specified MySQL hosts
原因:MySQL 服务没启动,或者连接字符串里的端口、主机不对。Windows 上 MySQL 默认端口 3306,但有些安装包会改成 3307。另外localhost在某些驱动版本里会走命名管道而不是 TCP,导致连不上。
解决:先在命令行跑mysql -u root -p -h 127.0.0.1 -P 3306确认能连。如果命令行能连但 C# 连不上,把连接字符串里的localhost换成127.0.0.1,强制走 TCP。再检查 MySQL 服务是否在运行:services.msc里找 MySQL80 服务,状态要是「正在运行」。
4.2 现象:中文书名存进去变成???或乱码
原因:建表时用了latin1字符集,或者连接字符串没指定CharSet=utf8mb4。还有一种情况是 C# 源文件保存时用了 GBK 编码,字符串常量本身就已经乱了。
解决:三步走——建表和字段都用utf8mb4;连接字符串加CharSet=utf8mb4;C# 文件用 UTF-8 with BOM 保存。如果已经建了表,用ALTER TABLE books CONVERT TO CHARACTER SET utf8mb4;改过来。注意utf8和utf8mb4不一样,MySQL 里的utf8只支持 3 字节,存不了 emoji 和某些生僻字,统一用utf8mb4。
4.3 现象:两个管理员同时借最后一本书,库存变成 -1
原因:借书代码先查库存再更新,中间没有锁。两个线程同时查到available_copies = 1,都认为可以借,然后都执行减一,结果库存变 -1。
解决:用SELECT ... FOR UPDATE在事务里锁行,如 3.2 节代码所示。或者用一条原子 SQL:UPDATE books SET available_copies = available_copies - 1 WHERE book_id = @bid AND available_copies > 0,然后判断ExecuteNonQuery()返回的影响行数,如果是 0 说明库存不足。后者更简洁,但要把插借阅记录也放在同一事务里。
4.4 现象:DataGridView 绑定数据后修改单元格,数据库没更新
原因:DataGridView 直接绑定了List<Book>,而List不实现IBindingList,单元格编辑不会触发任何事件。或者用了BindingSource但没调EndEdit()。
解决:用BindingList<Book>代替List<Book>,它实现了IBindingList,能通知 UI 更新。保存时遍历BindingList里状态为Modified或Added的项,逐条执行 UPDATE 或 INSERT。更省事的做法是用DataTable+MySqlDataAdapter,adapter.Update(dataTable)自动生成增删改语句,但要注意它默认不会处理并发冲突。
4.5 现象:程序运行一段时间后报Too many connections
原因:每次操作都new MySqlConnection但没Dispose,连接池被占满。或者MySqlDataReader没关闭,导致连接一直处于InUse状态。
解决:所有MySqlConnection、MySqlCommand、MySqlDataReader都用using包起来。连接池默认最大 100,正常用using不会超。如果确实需要调大,在连接字符串加Max Pool Size=200。排查时可以在 MySQL 里跑SHOW PROCESSLIST;看有多少连接处于Sleep状态,如果很多,说明连接没释放。
5. 进阶技巧:用存储过程封装借阅统计,顺带聊聊验证方法
简易系统跑通之后,下一步通常是加统计功能,比如「本月借阅排行榜」「逾期未还名单」。这些用 C# 拼 SQL 也能做,但 SQL 会越拼越长,不如把逻辑收进 MySQL 存储过程。下面这个存储过程返回借阅次数最多的前 N 本书:
DELIMITER // CREATE PROCEDURE GetTopBorrowedBooks(IN topN INT) BEGIN SELECT b.title, b.author, COUNT(br.record_id) AS borrow_count FROM books b JOIN borrow_records br ON b.book_id = br.book_id GROUP BY b.book_id, b.title, b.author ORDER BY borrow_count DESC LIMIT topN; END // DELIMITER ;C# 端调用:
public DataTable GetTopBooks(int topN) { var dt = new DataTable(); using (var conn = new MySqlConnection(connStr)) using (var cmd = new MySqlCommand("GetTopBorrowedBooks", conn)) { cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@topN", topN); using (var adapter = new MySqlDataAdapter(cmd)) { adapter.Fill(dt); } } return dt; }逻辑说明:CommandType.StoredProcedure告诉驱动这是存储过程而不是普通 SQL。MySqlDataAdapter自动打开和关闭连接,Fill把结果灌进DataTable,直接绑 DataGridView 就能显示。存储过程的好处是统计逻辑改的时候不用重新编译 C# 程序,改完 SQL 直接生效。
参数说明:topN是输入参数,调用时传 10 就是前 10 名。LIMIT在存储过程里可以用变量,但某些旧版本 MySQL 不支持LIMIT @topN,需要用PREPARE语句动态拼,这里为了简洁假设用的是 MySQL 5.7 以上版本。
验证方法上,我习惯用「双端对账」:在 C# 里执行一次借书,然后立刻去 MySQL Workbench 里跑SELECT * FROM borrow_records ORDER BY record_id DESC LIMIT 1;和SELECT available_copies FROM books WHERE book_id = X;,看记录和库存是否同步变化。如果对不上,就在 DAL 层加日志,把执行的 SQL 和参数打出来,对比数据库实际执行结果。这个习惯帮我省了很多「玄学」调试时间——很多时候不是代码逻辑错,而是参数传错或者事务没提交。
最后说个我自己的教训:别在 UI 线程里直接跑数据库查询。WinForms 里按钮点击事件是 UI 线程,如果查询慢,界面会卡成白屏。我一般用async/await配MySqlConnector的异步方法,或者至少把查询丢到Task.Run里,查完再用Invoke回 UI 线程更新控件。这个改动不大,但体验差别巨大。希望帮到你。
本文还有配套的精品资源,点击获取