C#图书管理系统实战:从数据库设计到罚金计算全解析
2026/9/12 20:13:26 网站建设 项目流程

简介:这是一份基于C#与Visual Studio开发、搭配SQL Server 2008数据库的图书管理系统完整工程,适合正在做课程设计或练习桌面应用开发的读者参考。系统覆盖读者信息、图书信息、借阅信息与用户信息四类核心数据表,实现了管理员对读者、图书、借阅记录的增删改查,以及按读者查询未还图书;读者端则可查看馆藏、个人借阅与未还书籍。借书还书时逾期罚金会自动计算,并支持罚款缴纳与账户信息修改,业务流程比较完整。压缩包共105个文件,以42个.cs源码文件为主,配合20个.resx资源与20个.resources资源,另含可直接运行的.exe程序以及SQL Server的.mdf/.ldf数据库文件,包体仅623KB,便于快速部署研究。截至当前已有2737人学习下载,适合需要对照完整项目结构、理解三层事务处理与数据库关联操作的C#学习者。

1. 一套能跑的 C# 图书管理系统长什么样

一套基于 C# 的图书管理系统,配合 Visual Studio 和 SQL Server2008,是很多课程设计、毕业设计和中小型资料室最常采用的组合。它的核心价值不在界面多好看,而是把“读者—图书—借阅—罚款”这条主链路拆干净:四张表承载业务,WinForms 窗体按角色分开,管理员和读者各自只看到自己能操作的功能。

这套系统能自动处理逾期罚款,这是它比 Excel 记录强的地方。借书时写入应还日期,还书时按实际日期计算罚金,不用人工翻记录。适合馆藏几百到几千册的资料室,也适合想从零看一遍 C# 桌面程序如何连接 SQL Server2008 的开发者。

下面直接从数据库设计开始拆,然后是 Visual Studio 里的 WinForms 分层实现,再落到借还书和罚金计算的边界问题,最后给出我实际调试老项目时踩过的坑。这样即使你拿到源码包后不急着跑起来,也能知道每段代码停在哪、为什么停在哪。

2. 数据库设计:四张表把借阅业务卡住

2.1 四张表的字段规划

这个系统的业务边界很明确:管理员登录用用户信息表,读者信息、图书信息、借阅信息各占一张表。罚款没有单独成表,而是放在借阅表里。这样每一次借还记录自带处罚结果,查历史罚款时直接按借阅记录统计,不需要再做一次关联。

表结构如下:

表名核心字段作用
user_infouser_id, user_name, password, role管理员账号,role 区分权限
reader_inforeader_id, reader_name, gender, department, phone, reg_date, password读者档案
book_infobook_id, book_name, author, publisher, isbn, total_count, available_count图书库存
borrow_infoborrow_id, book_id, reader_id, borrow_date, due_date, return_date, fine_amount借阅流水和罚款

字段命名尽量直白。一般主键用定长CHAR,图书馆的借书证号和图书条码通常是固定长度,定长字段在 SQL Server2008 里的索引查找更快;书名、人名这种可能变长的内容用NVARCHAR,避免中文乱码。

建库脚本先创建几张表,日期字段用DATETIME并让borrow_date默认取当前时间:

CREATE TABLE book_info ( book_id CHAR(10) PRIMARY KEY, book_name NVARCHAR(50) NOT NULL, author NVARCHAR(20) NULL, publisher NVARCHAR(50) NULL, isbn NVARCHAR(20) NULL, total_count INT NOT NULL DEFAULT(1), available_count INT NOT NULL DEFAULT(1), location_desc NVARCHAR(30) NULL ); CREATE TABLE borrow_info ( borrow_id INT IDENTITY(1,1) PRIMARY KEY, book_id CHAR(10) NOT NULL, reader_id CHAR(10) NOT NULL, borrow_date DATETIME NOT NULL DEFAULT(GETDATE()), due_date DATETIME NOT NULL, return_date DATETIME NULL, fine_amount DECIMAL(10,2) NOT NULL DEFAULT(0) );

borrow_idIDENTITY(1,1)自增,避免多个人同时手写主键冲突。available_count表示当前可借数量,它与total_count配合,后面的借书事务会用到这个字段。注意fine_amountDECIMAL(10,2),如果只取FLOAT,涉及小数金额时可能出现类似 0.30000000000000004 的精度问题。

2.2 外键约束与删除策略

借阅表是中间表,必须让book_idreader_id只能引用真实存在的图书和读者。建外键最常见的坑是删除数据时被约束卡住,比如读者还有未还图书时删不掉。我的建议是外键保持默认的NO ACTION,不级联删除,把“是否允许删除”的判断放到程序里。

ALTER TABLE borrow_info ADD CONSTRAINT FK_borrow_reader FOREIGN KEY (reader_id) REFERENCES reader_info(reader_id); ALTER TABLE borrow_info ADD CONSTRAINT FK_borrow_book FOREIGN KEY (book_id) REFERENCES book_info(book_id);

这里故意不写ON DELETE CASCADE,否则误删一个读者会把他的所有借阅记录一起删掉,连罚款历史都没了。业务上删除读者前应该先查borrow_info中是否有return_date IS NULL的记录,有未还图书就先提示“请先完成还书”。

给常用查询字段加普通索引,也是这套系统容易被忽略的部分:

CREATE INDEX IX_borrow_reader ON borrow_info(reader_id); CREATE INDEX IX_borrow_returndate ON borrow_info(return_date);

reader_id加索引后,读者端查询自己的未还图书更快;return_date加索引后,“查所有未还记录”这个高频动作就不会每次都整表扫描。SQL Server2008 对这类普通索引的维护成本很低,可以放心加。

2.3 初始化数据与 SQL Server2008 注意事项

直接用INSERT给基础表填充测试数据。管理员密码一般不要明文存,但 SQL Server2008 对新式哈希函数的支持很有限,早期项目里更多是把密码在 C# 端做一次 MD5 或 SHA1 再入库。

INSERT INTO user_info (user_id, user_name, password, role) VALUES ('admin', '系统管理员', 'admin123', 'A'); INSERT INTO reader_info (reader_id, reader_name, department, phone, reg_date, password) VALUES ('R000000001', N'张明', N'技术部', '13800000000', GETDATE(), '123456');

在 SQL Server2008 里要注意批处理分隔符GO。如果脚本里有多段CREATE TABLEALTER TABLE,最好在每段之间加上GO,因为有些语句的语法检查依赖批处理边界。另外 2008 不支持TRIM等新函数,写查询时用LTRIMRTRIM代替,避免整段脚本在低版本数据库上报“未定义函数”。

3. Visual Studio 里的 WinForms 分层:从窗体到数据访问

3.1 源码包的窗体结构

从项目文件里能直接看出这套 WinForms 程序的模块划分:Form1是登录后的主界面,FrmAdd负责新增读者或图书,Frmdeletebook负责删除图书,Frmnewborrow处理借书,Frmnewpunish处理罚款缴纳。每个Designer.cs只放控件布局,业务代码在对应的.cs文件里。

窗体主要职责相关数据表
Form1登录、主界面、按角色加载不同按钮user_info
FrmAdd新增读者、新增图书reader_info, book_info
Frmdeletebook删除图书、下架图书book_info
Frmnewborrow借书、还书操作borrow_info, book_info
Frmnewpunish查看未还记录、缴纳罚款borrow_info

一个窗体对应一个业务动作,比把所有功能堆在一个 Form 里好维护。登录后的角色控制是关键:管理员能进 FrmAdd、Frmdeletebook,读者只能进查询和修改密码的窗口。可以在Form1_Load时按role字段隐藏对应按钮。

3.2 连接 SQL Server2008 的统一入口

程序在 Visual Studio 里调试,最常放错的地方是连接字符串。如果本机装的是 SQL Server2008 Express,实例名默认是.\SQLEXPRESS;标准版通常直接写.。这里把连接字符串独立成一个静态类,避免每个窗体各写一遍。

using System.Data.SqlClient; public static class DbHelper { public static readonly string ConnStr = @"Data Source=.\SQLEXPRESS;Initial Catalog=BookManage;Integrated Security=True;"; public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (SqlConnection conn = new SqlConnection(ConnStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteNonQuery(); } } public static SqlDataReader ExecuteReader(string sql, params SqlParameter[] parameters) { SqlConnection conn = new SqlConnection(ConnStr); SqlCommand cmd = new SqlCommand(sql, conn); if (parameters != null) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteReader(CommandBehavior.CloseConnection); } }

ExecuteNonQueryusing管理连接和命令资源,适合增删改和返回行数的操作。ExecuteReader不能直接套用同一个写法,因为 DataReader 是延迟流式读取,用完需要关闭;这里用CommandBehavior.CloseConnection,让 reader 执行Close()时自动关连接,减少遗漏。参数化查询是底线,用户输入一旦拼接进 SQL 字符串,就有注入风险。

3.3 增删改查的写法与边界

新增读者时,用参数对象给 SQL 填值:

string sql = @"INSERT INTO reader_info (reader_id, reader_name, gender, department, phone, reg_date, password) VALUES (@id, @name, @gender, @department, @phone, GETDATE(), @pwd)"; SqlParameter[] paras = { new SqlParameter("@id", txtId.Text.Trim()), new SqlParameter("@name", txtName.Text.Trim()), new SqlParameter("@gender", cmbGender.SelectedItem.ToString()), new SqlParameter("@department", txtDept.Text.Trim()), new SqlParameter("@phone", txtPhone.Text.Trim()), new SqlParameter("@pwd", txtPwd.Text) }; int rows = DbHelper.ExecuteNonQuery(sql, paras); if (rows > 0) MessageBox.Show("读者添加成功");

reg_date直接交给数据库用GETDATE()生成,少一次往返。要特别检查主键冲突:如果reader_id已经存在,ExecuteNonQuery会抛SqlException,错误码 2627 对应主键重复。程序里应该try-catch这个异常后提示“编号已存在”,否则用户会看到一个长英文报错,体验很差。

删除图书的边界放在Frmdeletebook里,先查borrow_info是否有该书记录,有就不允许物理删除:

string checkSql = "SELECT COUNT(*) FROM borrow_info WHERE book_id = @bookId"; using (SqlDataReader reader = DbHelper.ExecuteReader(checkSql, new SqlParameter("@bookId", txtBookId.Text.Trim()))) { if (reader.Read() && reader.GetInt32(0) > 0) { MessageBox.Show("该书已有借阅记录,不能直接删除,可下架处理"); return; } } string delSql = "DELETE FROM book_info WHERE book_id = @bookId"; DbHelper.ExecuteNonQuery(delSql, new SqlParameter("@bookId", txtBookId.Text.Trim()));

这段代码先读后删,在单机或小并发场景下够用。更严格的方式是把“查询是否存在”和“执行删除”放进同一个事务,后面借书流程会展示事务写法。DataGridView 和数据源的绑定也很直接:查询出DataTable后设置dataGridView1.DataSource = dt;,列名会自动对应字段名,不需要手动逐个控件填充。

4. 借书还书的核心流程:让罚金自动算出来

4.1 借书时的并发保护

借书不是写一条INSERT那么简单。同一本书可能有多个副本,available_count代表当前可借数量。如果两个窗口同时看到可借 1 本,然后都执行插入,最后库存会变成负数。C# 端用事务加锁保证数据一致:

借阅状态判断条件程序动作
在借未逾期return_date IS NULL 且 GETDATE() <= due_date显示“未到期”
在借逾期return_date IS NULL 且 GETDATE() > due_date还书时自动计算罚金
已还return_date IS NOT NULL记录中保留罚款金额,供 Frmnewpunish 缴纳

核心借书方法:

public void BorrowBook(string bookId, string readerId, int days) { string checkSql = @"SELECT available_count FROM book_info WITH(UPDLOCK, ROWLOCK) WHERE book_id = @bookId"; string updateSql = @"UPDATE book_info SET available_count = available_count - 1 WHERE book_id = @bookId AND available_count > 0"; string insertSql = @"INSERT INTO borrow_info (book_id, reader_id, borrow_date, due_date) VALUES (@bookId, @readerId, GETDATE(), DATEADD(DAY, @days, GETDATE()))"; using (SqlConnection conn = new SqlConnection(DbHelper.ConnStr)) { conn.Open(); using (SqlTransaction tx = conn.BeginTransaction()) using (SqlCommand cmd = conn.CreateCommand()) { cmd.Transaction = tx; try { cmd.CommandText = checkSql; cmd.Parameters.AddWithValue("@bookId", bookId); int available = (int)cmd.ExecuteScalar(); if (available <= 0) throw new Exception("库存不足"); cmd.CommandText = updateSql; cmd.Parameters.AddWithValue("@bookId", bookId); int update = cmd.ExecuteNonQuery(); if (update == 0) throw new Exception("更新失败,可能已经无库存"); cmd.CommandText = insertSql; cmd.Parameters.AddWithValue("@readerId", readerId); cmd.Parameters.AddWithValue("@days", days); cmd.ExecuteNonQuery(); tx.Commit(); } catch { tx.Rollback(); throw; } } } }

WITH(UPDLOCK, ROWLOCK)是 SQL Server2008 支持的锁提示。UPDLOCK防止两个事务同时读到相同available_countROWLOCK缩小锁粒度,避免整表被锁住。事务的意义在于:插入借阅记录失败时,扣减库存的操作也能回滚,两个动作要么都成功,要么都不发生。

4.2 还书时计算逾期罚金

逾期罚金不需要定时任务,还书那一刻计算一次即可。常见规则是每本每天 0.1 元,不足一天按一天算。我一般直接在 SQL 里算,不取回 C# 再循环:

private void btnReturn_Click(object sender, EventArgs e) { string sql = @"UPDATE borrow_info SET return_date = GETDATE(), fine_amount = CASE WHEN GETDATE() > due_date THEN DATEDIFF(DAY, due_date, GETDATE()) * 0.1 ELSE 0 END WHERE borrow_id = @borrowId AND return_date IS NULL"; int rows = DbHelper.ExecuteNonQuery(sql, new SqlParameter("@borrowId", txtBorrowId.Text.Trim())); if (rows == 0) { MessageBox.Show("未找到未还记录,可能已经还过"); return; } string updateBook = @"UPDATE book_info SET available_count = available_count + 1 WHERE book_id = (SELECT book_id FROM borrow_info WHERE borrow_id = @borrowId)"; DbHelper.ExecuteNonQuery(updateBook, new SqlParameter("@borrowId", txtBorrowId.Text.Trim())); }

DATEDIFF(DAY, due_date, GETDATE())返回两个日期相差的天数。这里把“还书时间”和“计算罚金”放进同一个 UPDATE,保证只有一次状态变更。问题在于第二个UPDATE book_info和第一个 UPDATE 不是同一个事务,如果加库存失败,会出现“还书记录存在但库存没恢复”的情况。正式代码里应该把两个 UPDATE 放进一个SqlTransaction

罚金系数 0.1 硬编码在 SQL 里,以后要改规则就得改代码。更好的做法是把罚金单价放进一张配置表,或者由Frmnewpunish作为参数传入,这样管理员可以在界面上调。

4.3 读者端的未还图书查询与罚款缴纳

读者登录后只查自己的未还记录,条件是reader_id = @id AND return_date IS NULL,同时显示是否逾期:

string sql = @"SELECT b.book_name, br.borrow_date, br.due_date, CASE WHEN GETDATE() > br.due_date THEN N'已逾期' ELSE N'未到期' END AS state FROM borrow_info br JOIN book_info b ON br.book_id = b.book_id WHERE br.reader_id = @readerId AND br.return_date IS NULL ORDER BY br.due_date";

CASE WHEN生成计算列,直接在 DataGridView 绑定就能看到状态。最容易犯的错是漏掉JOIN book_info,只查borrow_info,界面上只能看到书号,看不到书名。

罚款缴纳在Frmnewpunish里,操作逻辑是把某条已经还书但fine_amount > 0的记录标记为已缴。如果有pay_status字段就更新这个字段,没有的话可以在fine_amount旁边加一个fine_paid字段。保持简单的方式:

string sql = @"UPDATE borrow_info SET fine_status = 'P' WHERE borrow_id = @borrowId AND fine_amount > 0"; int rows = DbHelper.ExecuteNonQuery(sql, new SqlParameter("@borrowId", txtBorrowId.Text.Trim())); if (rows == 0) MessageBox.Show("该书没有待缴罚款");

这里我给原表补了fine_status字段。如果不改表结构,就要设计一个“待缴罚款视图”,通过return_date IS NOT NULL AND fine_amount > 0 AND pay_status IS NULL过滤。缴纳动作不要用 DELETE 清掉fine_amount,否则之后对账看不到这笔历史。

5. 编译、部署与版本兼容的排错技巧

5.1 SQL Server2008 连接失败的常见原因

SQL Server2008 连接不上,绝大多数是实例名和身份认证问题。先用 SQL Server Management Studio 登录,确认服务启动了,再检查连接串里的Data Source。如果要用sa登录,数据库必须设置成“SQL Server 和 Windows 身份验证模式”,并在连接串里加上User ID=sa;Password=...

SQL Server2008 在 Windows 10/11 上安装时可能卡在“要求重新启动计算机”。这个提示是因为安装程序检测到注册表里有挂起的文件重命名操作。常规处理是删除HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager下的PendingFileRenameOperations2,再重新安装。修改注册表前先备份该项。

5.2 Visual Studio 新版打开老项目的问题

用 Visual Studio 2022 打开 2010 年前后的 C# 项目,解决方案管理器会提示“需要更新”。选择升级即可,WinForms 项目里的Form1.cs.Designer.cs结构基本兼容。如果遇到“由于出现错误,无法启动 Visual Studio。Microsoft.ServiceHub.Client.Controller”这个报错,多半是 ServiceHub 进程残留或缓存损坏。关掉 VS,在任务管理器结束所有ServiceHub.*进程,再删除%TEMP%\ServiceHub目录,重新启动。

现象排查顺序
无法连接 SQL Server服务启停 → 连接串 → 防火墙端口
VS 启动报 ServiceHub 错误结束 ServiceHub 进程 → 清缓存 → 修复 VS
老项目编译报缺少引用目标框架改为 .NET Framework 4.x → 重新添加 System.Data

5.3 用一条 SQL 验证整套规则

验证罚金是否计算正确的快捷方法,是在查询窗口直接模拟超期状态:

SELECT borrow_id, book_id, reader_id, DATEDIFF(DAY, due_date, GETDATE()) AS overdue_days, CASE WHEN return_date IS NULL AND GETDATE() > due_date THEN DATEDIFF(DAY, due_date, GETDATE()) * 0.1 ELSE fine_amount END AS fine FROM borrow_info WHERE return_date IS NULL

这段查询能同时看到超期天数和应罚金额。日常开发时可以把它保存为视图v_overdue_fineFrmnewpunish查询时直接写SELECT * FROM v_overdue_fine WHERE reader_id = @rid,这样既减少 C# 端的循环,也方便排查同一本书在不同时间还书时罚金是否一致。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询