ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL Server图书管理系统课程设计:建表、借还事务与统计自检

SQL Server图书管理系统课程设计:建表、借还事务与统计自检

简介:SQL 数据库图书管理系统完整代码文档面向数据库课程设计、毕业设计及 SQL 入门进阶学习者,围绕图书馆借阅业务中信息分散、人工管理效率低的问题,给出从需求分析到数据库落地的完整参考。压缩包仅含 1 个 doc 文件,约 709KB,将设计报告与代码内容整合在同一文档中,便于按章节查阅和摘取。文档覆盖读者、图书、操作员三类基础数据,以及借阅、还书、超期罚款等业务记录,并展开数据库存储设计、数据需求、E-R 图、数据字典、关系模式、关系实例与 SQL 查询实现,读者可据此梳理实体关系、表结构及增删改查语句的组织方式。文中还说明系统实施步骤与设计目标,强调自动化管理、减少人力物力浪费,并列出读者信息、书籍类别、库存、借还及罚款等模块的输入、查询、修改功能。已有 2319 人学习,适合需要完成课程项目、对照关系型数据库设计流程或补齐 SQL 实践案例的读者参考。

1. 一份 2013 年的课程设计 SQL 脚本,现在还有人翻出来跑

翻资料库时又看到这类文件:名字叫「SQL 数据库图书管理系统(完整代码)」,里面是一份 .doc 的课程设计报告书,前半截是 E-R 图、数据字典、关系模式,后半截直接贴 T-SQL——建库、建表、灌数据、单表查询,一路贴到底,还带「完整代码」四个字。它不是工业级系统,但它把「实体识别 → 关系模式 → 建表约束 → 业务 SQL」这条链路完整走了一遍,而且每一步都留了能直接执行的语句。我参与过几次数据库课程设计的评审,最常见的翻车从来不是不会写 SELECT,而是表建完了外键挂不上、借书状态位和借阅流水对不上、还书之后罚款被算了两遍。这份文档的价值恰好在这里:它给了一个能跑通的最小闭环,你可以照着复现,也可以拿它当靶子改——把里面几处明显的主键设计问题掰正,正好是一次完整的复盘。适合三类人:赶课程设计的学生、要快速搭个借阅原型的开发者、想把 SQL 语法重新捋顺的转岗同学。

2. 拆表:六张表怎么把「借—还—罚」这条链兜住

先把原料里的六个关系模式摆出来:书籍类别、读者、书籍、借阅、还书、罚款。这是典型的「主数据 + 流水 + 派生」三层结构,理解了这个分层,后面所有字段和约束的选择都不用死记。

2.1 从 E-R 图到关系模式的落点

原文档画了六张实体 E-R 图再加一张总图,实体是类别、读者、书籍、借阅记录、归还记录、罚款。实体好认,真正决定后面好不好用的是联系怎么落地。书籍和类别是 1:N,落到system_books里就是一个bookstyleno外键,不需要中间表;读者和书籍是 M:N,必须落成一张独立的借阅表,这张表的主键选择是全篇最关键的一处;还书严格说是借阅这条流水的一次状态变更,原文档把它单独建成return_record,这个拆法在课程设计里很常见,好处是「借阅历史」和「归还历史」各自可查,坏处是两张表靠(bookid, readerid)关联,一旦同一本书被同一个人借了两次,关联就退化成多对多,查出来的还书日期会对错行。

我一般会这么处理:借阅表用自增代理键borrowid,还书表引用borrowid,这样一次借阅生命周期内所有事件都能精确串起来;同时保留borrowdate冗余在还书表里,纯粹是为了报表少一次 JOIN。罚款表同理,挂borrowid而不是bookid,因为罚款罚的是「这一次超期」,不是「这本书」。

再说那个isborrowed字段。它是典型的状态冗余:有没有被借出,完全可以从借阅流水里推出来(存在借阅记录且没有对应归还记录即为借出)。那为什么还留着?因为列表页要展示几百本书的借出状态,每行都去 EXISTS 子查询一次,代价不划算。冗余状态位的代价是必须靠事务保证一致,这也是后面第 4 章要用显式事务包住借还操作的原因。

2.2 字段类型与主外键的取舍

原文档的字段类型有几处一看就是赶时间写的,直接照抄会在真实数据上出问题,逐条说。

原字段原类型建议改法理由
readersexvarchar(2)char(1) + CHECK性别只存一个字符,长度 2 会混进「未知」这类脏值
bookfeevarchar(30)decimal(8,2)金额用字符串存,SUM 会做隐式转换,排序按字典序,0.5 排在 10 前面
bookpubdatedatetimedate出版日期没有时分秒,用 datetime 会引入 00:00:00 噪声,比较时容易踩边界
borrow_record.bookidprimary key代理键borrowid原设计里 bookid 是主键,意味着一本书一辈子只能被借一次
isborrowedvarchar(2)char(1) + CHECK ('0','1')语义要写死:'1' 在馆,'0' 借出,别两处反着用

外键的删除行为也要想清楚。system_books被borrow_record引用,用默认的 NO ACTION,意味着有借阅历史的书删不掉——这是对的,业务上不该物理删除,加个isactive软删标记更稳。反过来book_style被书籍引用,类别删不掉也是对的。原文档没写 ON DELETE / ON UPDATE,全都落到默认行为,这个保守选择反而没错,别手贱加 CASCADE,级联删会把历史流水一起带走。

还有一个容易被忽略的点:字符集和排序规则。课程设计里读者姓名、书名基本是中文,varchar在非中文排序规则下会存成问号。稳妥做法是姓名、书名、出版社这些用nvarchar,编号类用varchar。原文档全部用 varchar,在默认安装的实例上跑出来中文变乱码是常事——这不是 SQL 写错了,是类型选错了。

2.3 建库建表脚本:把上面这些改动落成可执行代码

原文档的建库语句里FILENAME = 'c:\'只给了目录没给文件名,在 SQL Server 上必报错,直接改成完整路径。下面这版可以直接在 SSMS 里整段执行,注意把数据目录换成你机器上真实存在的路径。

-- 建库:先判断再删,避免重复执行报「数据库已存在」 IF DB_ID('LibraryDB') IS NOT NULL ALTER DATABASE LibraryDB SET SINGLE_USER WITH ROLLBACK IMMEDIATE; IF DB_ID('LibraryDB') IS NOT NULL DROP DATABASE LibraryDB; GO CREATE DATABASE LibraryDB ON (NAME = 'LibraryDB_data', FILENAME = 'D:\SQLData\LibraryDB_data.mdf', -- 必须是完整文件路径,不能只写目录 SIZE = 10MB, MAXSIZE = 500MB, FILEGROWTH = 10MB) LOG ON (NAME = 'LibraryDB_log', FILENAME = 'D:\SQLData\LibraryDB_log.ldf', SIZE = 5MB, MAXSIZE = 200MB, FILEGROWTH = 10MB); GO USE LibraryDB; GO -- 1. 书籍类别:被书籍表引用,必须最先建 CREATE TABLE dbo.book_style ( bookstyleno varchar(10) NOT NULL PRIMARY KEY, bookstyle nvarchar(30) NOT NULL ); GO -- 2. 读者 CREATE TABLE dbo.system_readers ( readerid varchar(12) NOT NULL PRIMARY KEY, readername nvarchar(20) NOT NULL, readersex char(1) NOT NULL CONSTRAINT ck_readersex CHECK (readersex IN ('男','女')), readertype varchar(10) NULL, regdate datetime NOT NULL CONSTRAINT df_regdate DEFAULT (GETDATE()) ); GO -- 3. 书籍:bookstyleno 外键指向类别;isborrowed 语义固定为 '1'在馆 '0'借出 CREATE TABLE dbo.system_books ( bookid varchar(20) NOT NULL PRIMARY KEY, bookname nvarchar(60) NOT NULL, bookstyleno varchar(10) NOT NULL, bookauthor nvarchar(30) NULL, bookpub nvarchar(50) NULL, bookpubdate date NULL, bookindate datetime NULL, isborrowed char(1) NOT NULL CONSTRAINT ck_isborrowed CHECK (isborrowed IN ('0','1')), CONSTRAINT fk_books_style FOREIGN KEY (bookstyleno) REFERENCES dbo.book_style (bookstyleno) ); GO -- 4. 借阅流水:代理键 borrowid 是关键改动,duedate 用来算超期 CREATE TABLE dbo.borrow_record ( borrowid int IDENTITY(1,1) NOT NULL PRIMARY KEY, bookid varchar(20) NOT NULL, readerid varchar(12) NOT NULL, borrowdate datetime NOT NULL CONSTRAINT df_borrowdate DEFAULT (GETDATE()), duedate datetime NOT NULL, -- 应还日期,借出时按规则算好写入 CONSTRAINT fk_borrow_book FOREIGN KEY (bookid) REFERENCES dbo.system_books (bookid), CONSTRAINT fk_borrow_reader FOREIGN KEY (readerid) REFERENCES dbo.system_readers (readerid) ); CREATE INDEX ix_borrow_book ON dbo.borrow_record (bookid, borrowid DESC); GO -- 5. 还书流水:引用 borrowid,一次借阅只允许一条归还记录 CREATE TABLE dbo.return_record ( returnid int IDENTITY(1,1) NOT NULL PRIMARY KEY, borrowid int NOT NULL UNIQUE, -- UNIQUE 保证不会重复还书 readerid varchar(12) NOT NULL, bookid varchar(20) NOT NULL, returndate datetime NOT NULL CONSTRAINT df_returndate DEFAULT (GETDATE()) ); GO -- 6. 罚款:金额必须是 decimal,挂 borrowid 而不是 bookid CREATE TABLE dbo.reader_fee ( feeid int IDENTITY(1,1) NOT NULL PRIMARY KEY, borrowid int NOT NULL UNIQUE, -- 一次超期只罚一次 readerid varchar(12) NOT NULL, bookid varchar(20) NOT NULL, bookfee decimal(8,2) NOT NULL CONSTRAINT df_fee DEFAULT (0), borrowdate datetime NULL, ispaid char(1) NOT NULL CONSTRAINT df_paid DEFAULT ('0') ); GO

逻辑说明几处关键点。borrowid用IDENTITY自增,插入时不用管;它在return_record和reader_fee上都加了UNIQUE,等于在数据库层面把「一次借阅只能还一次、只能罚一次」这两条业务规则钉死了,应用层写错也进不去脏数据。duedate冗余在借阅表里,而不是每次算borrowdate + 30,是因为借期可能按读者类型分级(教师 60 天、学生 30 天),把它物化下来,查询超期时不用再关联读者表。

参数怎么调:SIZE和FILEGROWTH按课程作业的量给 10MB 足够,生产环境一般把FILEGROWTH设成固定 MB 而不是百分比,避免文件长大后每次扩 10% 造成长时间阻塞。MAXSIZE如果你打算在容器里跑,留UNLIMITED或者给足,不然日志撑满会直接拒绝写入。

3. 灌数据与造场景:初始化脚本怎么写才不锁死自己

建完空表跑一句SELECT * FROM system_books是空的,看不出对错。这一章解决的是「怎么把一份可复现的测试数据灌进去」,以及灌的过程中外键、状态位、日期这三样怎么不打架。

3.1 初始化顺序:外键决定你的 INSERT 排队

原文档的初始化脚本是一串insert into ... values (...),顺序是类别 → 书籍 → 读者 → 借阅。这个顺序是对的,因为外键的依赖方向就是它。照抄的时候最容易翻车的是漏了GO或者漏了分号,导致下一条 INSERT 被当成上一条的延续。SQL Server 对;在某些语句里可以容忍,但对INSERT ... VALUES后面紧跟下一个INSERT是必须断开的。

正确姿势是把每个层级的插入分成独立批次,并且用显式列名——原文档这部分做得不错,列名都写了,不要偷懒写成INSERT INTO t VALUES (...),字段顺序一调整就全错位。

USE LibraryDB; GO -- 第一批:类别。外键的唯一上游,必须先落 INSERT INTO dbo.book_style (bookstyleno, bookstyle) VALUES ('01', N'小说'), ('02', N'计算机'), ('03', N'历史'), ('04', N'科普'), ('05', N'教材'); GO -- 第二批:书籍。bookstyleno 必须存在于上一批 INSERT INTO dbo.system_books (bookid, bookname, bookstyleno, bookauthor, bookpub, bookpubdate, bookindate, isborrowed) VALUES ('B0001', N'北岭旧事', '01', N'作者甲', N'示例出版社', '2019-03-01', '2023-05-25', '1'), ('B0002', N'雾中列车', '01', N'作者乙', N'示例出版社', '2020-07-12', '2023-05-26', '1'), ('B0003', N'T-SQL 入门', '02', N'作者丙', N'示例出版社', '2021-01-20', '2023-05-27', '1'), ('B0004', N'旧城手记', '03', N'作者丁', N'示例出版社', '2018-11-05', '2023-05-28', '1'); GO -- 第三批:读者 INSERT INTO dbo.system_readers (readerid, readername, readersex, readertype, regdate) VALUES ('R2023001', N'张一', '男', '学生', '2023-01-18 12:20'), ('R2023002', N'李二', '女', '学生', '2023-01-19 13:15'), ('T2023001', N'王三', '男', '教师', '2023-01-01 09:00'); GO

日期写法这里要留个心眼。'2019-03-01'这种YYYY-MM-DD格式在多数语言设置下能正确解析,但'01/03/2019'就会随SET LANGUAGE在「3 月 1 日」和「1 月 3 日」之间摇摆,这类问题排查起来极其费时。稳妥写法是统一用 ISO 8601 的YYYYMMDD(无分隔符)或者带T的'2019-03-01T00:00:00'。我一般在脚本开头加一句SET DATEFORMAT ymd;把会话级别的解析规则钉住。

3.2 状态位isborrowed的维护:别让它在两处反向

原文档里isborrowed的语义有点绕:初始插入时全是'1',然后借书时update ... set isborrowed=0,也就是'1'表示在馆。这个约定本身没问题,问题在于脚本里没有地方集中声明它,读到第 80 行的人已经忘了'1'是什么意思。血泪经验是:状态位的语义必须写在建表的 CHECK 约束和字段注释里,一处定义,全局引用。

灌初始化借阅数据时,必须把「插流水」和「改状态」放在同一个事务里,否则中途失败就会留下「有借阅记录但状态还是在馆」的鬼数据。

-- 模拟一次借书:两个动作必须原子 BEGIN TRAN; INSERT INTO dbo.borrow_record (bookid, readerid, borrowdate, duedate) VALUES ('B0003', 'R2023001', '2023-06-01 10:00', '2023-07-01 10:00'); UPDATE dbo.system_books SET isborrowed = '0' WHERE bookid = 'B0003' AND isborrowed = '1'; -- 条件里带旧状态,天然防并发 IF @@ROWCOUNT = 0 BEGIN ROLLBACK; THROW 50001, '书籍不存在或已被借出', 1; END COMMIT;

WHERE里带isborrowed = '1'这个技巧值得记住:它把「检查」和「更新」合成了一条语句,两个人同时借同一本书时,后到的那条UPDATE影响行数为 0,直接回滚,不会出现超借。这比先SELECT判断再UPDATE靠谱得多,后者在两条语句之间存在时间窗。

3.3 批量造数:两百本书五十个读者,别手写

课程设计的数据量小,但你要演示分页、演示统计、演示超期列表,四条数据不够看。手工拼INSERT太蠢,用递归 CTE 或者WHILE生成。注意递归 CTE 默认上限 100,超过必须加OPTION (MAXRECURSION 0)。

USE LibraryDB; GO -- 生成 200 本书,类别在 01~05 之间轮转,状态随机 ;WITH n AS ( SELECT 1 AS i UNION ALL SELECT i + 1 FROM n WHERE i < 200 ) INSERT INTO dbo.system_books (bookid, bookname, bookstyleno, bookauthor, bookpub, bookpubdate, bookindate, isborrowed) SELECT 'BX' + RIGHT('0000' + CAST(i AS varchar(4)), 4), N'示例图书' + CAST(i AS nvarchar(10)), RIGHT('0' + CAST((i % 5) + 1 AS varchar(2)), 2), N'作者' + CAST((i % 20) + 1 AS nvarchar(10)), N'示例出版社', DATEADD(DAY, -1 * (i * 7), CAST('2023-01-01' AS date)), DATEADD(DAY, -1 * i, CAST('2023-06-01' AS datetime)), '1' FROM n OPTION (MAXRECURSION 0); GO SELECT COUNT(*) AS 书籍总数 FROM dbo.system_books;

生成bookid用了RIGHT('0000' + CAST(i AS varchar(4)), 4)补零,保证编号定长、排序稳定。不补零的话B10会排在B9前面,列表页看着难受。bookpubdate用DATEADD往前推,保证不会出现未来日期——这个细节在演示「出版日期早于登记日期」的校验时会用到。

参数说明:MAXRECURSION 0表示不限制递归深度,只在生成量确定且可控时使用,别在开放查询里这么写。RIGHT('0' + ..., 2)是简易补零,超过两位会截断,所以类别编码上限是 99,够用。

灌完数据先跑一遍计数核对:书籍 200+4、读者 3、借阅 1,对不上就说明有批次失败了,别急着往下走。

4. 借书、还书、罚款:三条业务 SQL 的写法与事务边界

前面都是准备,这一章是系统真正跑起来的地方。三条业务各自对应一组 SQL,共同点是:都要改两张以上的表,都必须用事务包住。

4.1 借书:一条 INSERT 加一次 UPDATE,顺序不能反

借书的动作拆开是三步:校验读者存在、抢占书籍状态、写借阅流水。抢占状态放在写流水之前,是因为UPDATE的@@ROWCOUNT天然充当了乐观锁;先写流水再改状态,失败时要回滚两条,麻烦。

封装成存储过程,应用层只管传参。存储过程的好处是把业务规则收在一处,课程设计里可能用不到,但答辩时会被问「如果两个人同时借同一本书怎么办」,有这个就能答。

CREATE OR ALTER PROCEDURE dbo.sp_borrow_book @readerid varchar(12), @bookid varchar(20), @days int = 30 -- 借期天数,教师可传 60 AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; -- 出错自动回滚,省掉一堆 IF @@ERROR IF NOT EXISTS (SELECT 1 FROM dbo.system_readers WHERE readerid = @readerid) BEGIN THROW 50002, '读者不存在', 1; RETURN; END BEGIN TRAN; UPDATE dbo.system_books SET isborrowed = '0' WHERE bookid = @bookid AND isborrowed = '1'; IF @@ROWCOUNT = 0 BEGIN ROLLBACK; THROW 50003, '该书不存在或已被借出', 1; END INSERT INTO dbo.borrow_record (bookid, readerid, borrowdate, duedate) VALUES (@bookid, @readerid, GETDATE(), DATEADD(DAY, @days, GETDATE())); COMMIT; SELECT SCOPE_IDENTITY() AS new_borrowid; -- 返回主键给上层 END GO

逻辑说明:SET XACT_ABORT ON是让运行时错误自动回滚整个事务,比手动判@@ERROR干净得多。SCOPE_IDENTITY()而不是@@IDENTITY,后者会被触发器里的自增污染,这是老生常谈但每年都有人踩。@days给默认值 30,教师 60 由应用层判断后传入,把「借期规则」留在应用层,存储过程不硬编码读者类型——这样加一种读者类型不用改存储过程。

参数怎么改:@days如果要做成配置,可以建一张param_config表存「读者类型 → 借期」,存储过程里查一下。别把 30 和 60 写成CASE塞在 SQL 里,改一次要改脚本。

4.2 还书:先找借阅记录,再判定超期

还书的难点不是写流水,是「找到对的那条借阅记录」。因为一本书会被反复借还,(bookid, readerid)不唯一,必须靠「没有对应归还记录」这个条件筛出未还的那条。用borrowid关联后,这一步变成一句NOT EXISTS。

CREATE OR ALTER PROCEDURE dbo.sp_return_book @readerid varchar(12), @bookid varchar(20), @returndate datetime = NULL, -- 允许补录历史归还 @feerate decimal(8,2) = 0.20 -- 每天罚金,参数化便于调价 AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; IF @returndate IS NULL SET @returndate = GETDATE(); DECLARE @borrowid int, @duedate datetime; -- 取该读者该书最近一条尚未归还的借阅记录 SELECT TOP (1) @borrowid = r.borrowid, @duedate = r.duedate FROM dbo.borrow_record r WHERE r.bookid = @bookid AND r.readerid = @readerid AND NOT EXISTS (SELECT 1 FROM dbo.return_record t WHERE t.borrowid = r.borrowid) ORDER BY r.borrowid DESC; IF @borrowid IS NULL BEGIN THROW 50004, '找不到可归还的借阅记录', 1; RETURN; END BEGIN TRAN; INSERT INTO dbo.return_record (borrowid, readerid, bookid, returndate) VALUES (@borrowid, @readerid, @bookid, @returndate); UPDATE dbo.system_books SET isborrowed = '1' WHERE bookid = @bookid; -- 超期才产生罚款,一天都不到的按 0 处理 DECLARE @overdays int = DATEDIFF(DAY, @duedate, @returndate); IF @overdays > 0 INSERT INTO dbo.reader_fee (borrowid, readerid, bookid, bookfee, borrowdate) SELECT @borrowid, @readerid, @bookid, CAST(@overdays AS decimal(8,2)) * @feerate, r.borrowdate FROM dbo.borrow_record r WHERE r.borrowid = @borrowid; COMMIT; SELECT @overdays AS 超期天数, CASE WHEN @overdays > 0 THEN CAST(@overdays AS decimal(8,2)) * @feerate ELSE 0 END AS 罚金; END GO

DATEDIFF(DAY, @duedate, @returndate)的结果是「跨过了几个日期边界」,不是精确时长。周三 23:59 借、周四 00:01 还,DATEDIFF也是 1 天。这个行为在按天计费时恰好是想要的,但如果按小时计费就得换成DATEDIFF(HOUR, ...) / 24.0。这一点答辩很容易被追问,提前想清楚。

return_record.borrowid上的 UNIQUE 约束在这里是最后一道闸:万一存储过程被并发调用两次,第二次插入会撞唯一键报错,整个事务回滚,不会产生两条归还记录和两份罚款。

4.3 罚款的幂等:重复还书不能罚两次

幂等这个词在课程设计里通常没人提,但它是这类系统最容易出问题的地方。用户点两次「还书」按钮、接口超时重试、脚本补数据跑了两遍——任何一种都会让罚款翻倍。上面靠borrowid UNIQUE挡住了大部分,但还有一种情况:还书成功、罚款插入失败(比如金额溢出了decimal(8,2)),此时事务回滚,书籍状态回到借出,用户可以重新操作,这个路径是安全的。

真正危险的是「罚款表单独维护」。有些实现把罚款做成一个定时任务,每天扫一遍超期记录往reader_fee里插一条,任务重跑一次就多插一份。正确的做法是罚款只在还书那一刻计算一次,或者扫描任务按borrowid做MERGE:

-- 每天扫描仍在外借且已超期的记录,按 borrowid 幂等写入/更新罚款 MERGE dbo.reader_fee AS tgt USING ( SELECT r.borrowid, r.readerid, r.bookid, r.borrowdate, DATEDIFF(DAY, r.duedate, GETDATE()) AS overdays FROM dbo.borrow_record r WHERE NOT EXISTS (SELECT 1 FROM dbo.return_record t WHERE t.borrowid = r.borrowid) AND r.duedate < GETDATE() ) AS src ON tgt.borrowid = src.borrowid WHEN MATCHED THEN UPDATE SET bookfee = CAST(src.overdays AS decimal(8,2)) * 0.20 WHEN NOT MATCHED THEN INSERT (borrowid, readerid, bookid, bookfee, borrowdate) VALUES (src.borrowid, src.readerid, src.bookid, CAST(src.overdays AS decimal(8,2)) * 0.20, src.borrowdate);

MERGE的语义是「有则更新、无则插入」,天然幂等。注意MERGE在 SQL Server 上有若干已知的并发陷阱,用在单机课程作业没问题,生产环境要加HOLDLOCK或者干脆拆成UPDATE+INSERT两步。这里不展开,知道有这回事就行。

5. 避坑与排查:跑这份脚本最容易翻车的五个地方

这一章全部来自实际跑脚本时踩到的坑,每条按「现象 → 原因 → 解决」写。你如果正在照着原文的代码往机器上敲,建议先扫一眼再动手,能省两小时。

5.1 建库直接报「文件激活错误」

现象:执行CREATE DATABASE立刻失败,错误信息里带Operating system error 3或者文件激活错误,数据库进恢复挂起状态。

原因:原文档里FILENAME = 'c:\'只写了一个盘符目录,没有文件名。SQL Server 需要的是完整文件路径,包括扩展名.mdf/.ldf,而且要保证该目录对 SQL Server 服务账户可写。另外c:\根目录在较新版本上默认没有写权限。

解决:改成D:\SQLData\LibraryDB_data.mdf这种完整路径,目录提前手动建好;容器里跑就把路径指向/var/opt/mssql/data/。如果数据库已经卡在恢复挂起,先ALTER DATABASE ... SET EMERGENCY,再DBCC CHECKDB修,或者干脆删掉重来——课程作业的数据没有抢救价值。

5.2 同一本书第二次借阅时主键冲突

现象:第一次借B0003正常,还了之后再借,报违反 PRIMARY KEY 约束 'PK__borrow_r__...'。

原因:原设计把borrow_record的bookid设成主键,一本书在整个生命周期内只能有一条借阅记录。这是原文档最实质的设计缺陷,不是语法问题,照抄必踩。

解决:改成borrowid int IDENTITY PRIMARY KEY,bookid和readerid降级为普通外键列,再补一个(bookid, borrowid DESC)的索引支撑「查某本书的借阅历史」。如果不想改表结构,退而求其次用(bookid, readerid, borrowdate)做复合主键,但datetime精度到毫秒,两个人同一毫秒借同一本书的概率虽低,仍然是隐患。

5.3isborrowed查不出数据,WHERE isborrowed = 0永远返回空

现象:明明有书被借出去了,SELECT * FROM system_books WHERE isborrowed = 0一行都不返回;改成'0'才有结果。

原因:isborrowed是字符类型,WHERE isborrowed = 0会触发隐式转换,把字符列转成数值。SQL Server 的转换规则下'0'能转成 0,看起来应该匹配,但如果列里存的是'0 '(带空格,char(2)的典型产物)或者'N',转换就失败或匹配不上。更常见的是两处代码对语义理解相反:插入时'1'表示在馆,查询时按1表示借出写条件,逻辑上就反了。

解决:isborrowed用char(1)加 CHECK 约束,只允许'0'和'1',查询一律写字符字面量'0';把语义写进字段注释和建表脚本顶部。团队里如果有人习惯用bit,就统一用bit,别一张表 char 一张表 bit。

5.4 还书成功但书籍状态还是「借出」

现象:return_record里能看到归还记录,但列表页那本书仍显示借出,点还书又提示「找不到可归还的借阅记录」。

原因:还书的INSERT return_record和UPDATE system_books没有放在同一个事务里,中间报错(比如罚款表插入失败)导致前半段提交、后半段回滚。原文档的脚本是一条条顺序执行,没有任何事务包裹,中途断掉就留下这种半截状态。

解决:把还书的三个动作(写归还、改状态、插罚款)包进BEGIN TRAN ... COMMIT,并开SET XACT_ABORT ON。已经产生脏数据的,跑一次修复:

-- 找出「有未归还借阅记录但状态显示在馆」的书,把这些书状态纠正为借出 UPDATE b SET b.isborrowed = '0' FROM dbo.system_books b WHERE b.isborrowed = '1' AND EXISTS ( SELECT 1 FROM dbo.borrow_record r WHERE r.bookid = b.bookid AND NOT EXISTS (SELECT 1 FROM dbo.return_record t WHERE t.borrowid = r.borrowid) );

5.5 罚款金额加起来不对,SUM出来是字符串

现象:SELECT SUM(bookfee) FROM reader_fee报类型转换错误,或者结果明显偏小;排序时0.5排在10后面。

原因:bookfee用了varchar(30),SUM需要数值类型,隐式转换失败就报错;排序按字典序,'0.5'的首字符0小于'10'的1,所以排前面——看着像数值排序,其实是字符排序。

解决:把列类型改成decimal(8,2)。已有数据的表用ALTER TABLE ... ALTER COLUMN,前提是该列没有依赖(索引、约束);有依赖就先删约束再改。改完之后所有涉及金额的计算都在数值域完成,ROUND(x, 2)控制精度。

另外补一条不算坑但很容易忽略的:备份。课程设计的机器经常是公共机房,重启就还原。脚本写完先导出一份.sql全量脚本(SSMS 右键数据库 → 任务 → 生成脚本),比指望机器不还原靠谱得多。

6. 从「能跑」到「能查」:视图、统计与一份自检脚本

到第 5 章为止,系统是「能借能还」的水平。但课程设计报告书里有一节叫「统计数据:书籍借阅情况统计或读者借阅情况统计」,这一节才是拉开分数的地方——老师真正想看的是你会不会用聚合和视图把流水变成报表,而不是又贴一遍SELECT *。

先把最常用的三张报表沉淀成视图。视图的好处是把多表 JOIN 和状态判断封一次,应用层写SELECT * FROM v_borrow_detail WHERE 超期天数 > 0就行,不用每处都重复NOT EXISTS。

-- 借阅明细:把书籍、读者、归还、罚款四张表拼平成一行一次借阅 CREATE OR ALTER VIEW dbo.v_borrow_detail AS SELECT r.borrowid, r.readerid, d.readername, d.readertype, r.bookid, b.bookname, b.bookstyleno, s.bookstyle, r.borrowdate, r.duedate, t.returndate, -- 未归还时按当前时间算超期,已归还按归还时间算 DATEDIFF(DAY, r.duedate, ISNULL(t.returndate, GETDATE())) AS overdays, ISNULL(f.bookfee, 0) AS bookfee, CASE WHEN t.returndate IS NULL THEN N'在借' ELSE N'已还' END AS status FROM dbo.borrow_record r JOIN dbo.system_readers d ON d.readerid = r.readerid JOIN dbo.system_books b ON b.bookid = r.bookid JOIN dbo.book_style s ON s.bookstyleno = b.bookstyleno LEFT JOIN dbo.return_record t ON t.borrowid = r.borrowid LEFT JOIN dbo.reader_fee f ON f.borrowid = r.borrowid; GO -- 热门类别 TOP 5:按借阅次数排,不是按藏书量 SELECT TOP (5) bookstyle AS 类别, COUNT(*) AS 借阅次数, COUNT(DISTINCT readerid) AS 借阅读者数 FROM dbo.v_borrow_detail GROUP BY bookstyle ORDER BY COUNT(*) DESC;

这个LEFT JOIN的写法值得说一句:return_record和reader_fee都必须用LEFT,用INNER的话,在借的书和没罚款的记录会整行消失,报表里只剩「已还且有罚款」的借阅,统计结果直接错一半。教材上说「左连接保留左表全部行」,落到具体场景就是这一句。

再给一份自检脚本,跑完这套 SQL,四条计数应该全是 0:

检查项期望值异常含义
状态为在馆但存在未归还借阅0还书事务断了
状态为借出但没有任何未归还借阅0借书事务断了
有归还记录但没有对应借阅0外键被绕过或历史脏数据
罚款记录条数 > 同一borrowid的归还次数0罚款重复计算
-- 4 条自检合成一个结果集,任何一行数字不为 0 就说明数据不一致 SELECT '状态在馆但有未还记录' AS 检查项, COUNT(*) AS 异常行数 FROM dbo.system_books b WHERE b.isborrowed = '1' AND EXISTS (SELECT 1 FROM dbo.borrow_record r WHERE r.bookid = b.bookid AND NOT EXISTS (SELECT 1 FROM dbo.return_record t WHERE t.borrowid = r.borrowid)) UNION ALL SELECT '状态借出但无未还记录', COUNT(*) FROM dbo.system_books b WHERE b.isborrowed = '0' AND NOT EXISTS (SELECT 1 FROM dbo.borrow_record r WHERE r.bookid = b.bookid AND NOT EXISTS (SELECT 1 FROM dbo.return_record t WHERE t.borrowid = r.borrowid)) UNION ALL SELECT '罚款数超过归还数', COUNT(*) FROM (SELECT borrowid FROM dbo.reader_fee GROUP BY borrowid HAVING COUNT(*) > (SELECT COUNT(*) FROM dbo.return_record t WHERE t.borrowid = reader_fee.borrowid)) x;

这套自检我从那以后每次改完借还逻辑都强制跑一遍,不跑不敢提交——因为它抓过一次特别隐蔽的问题:某次给还书加了「补录历史归还日期」的参数,结果补录时GETDATE()和传入日期混用,导致状态位改对了、罚款按今天算的,报表上多出一批莫名其妙的超期。四条计数里第三条不为零,五分钟就定位到了。你要是拿这份课程设计当底子改,建议把这段直接贴进项目的启动检查里,比事后对账省事得多。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进