ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

ASP图书馆管理代码:借还书事务、并发与权限校验实战

ASP图书馆管理代码:借还书事务、并发与权限校验实战

简介:这份ASP图书馆管理代码面向Web开发初学者、课程设计或小型图书馆信息化场景,基于ASP与Access构建一套可运行的图书查询、借阅、归还及后台管理示例,帮助读者理解动态网页与关系型数据库的协作方式,适合课程设计、毕业设计参考与小型馆藏管理原型搭建。压缩包共53个文件,约426KB,其中31个ASP文件承载登录、图书维护、借阅还书与管理入口等服务端逻辑,4个JS和3个CSS负责前端交互与界面样式,另含Access的MDB数据库、DB文件、说明文本及图片等配套资源。已有55人学习。代码覆盖书籍表、读者表与借阅记录表的数据组织,并涉及SQL注入防护、MD5处理、登录校验和上传等实践点;读者可据此梳理小型管理系统的目录结构、页面分工与数据库读写流程,涵盖按书名作者分类检索、借阅状态更新及管理员增删图书等流程,也可作为课程设计或二次开发的基础模板。

1. 从「能跑就行」到「能验收」:ASP 图书馆管理代码真正的难点在哪

每年做 ASP 图书馆管理代码的同学,十个里有八个卡在同一个地方:增删改查一周就写完了,答辩前一天却发现借书功能在两个人同时点击时会多扣一本库存。图书馆管理系统看着是课程设计里最没有技术含量的题目,实际上它把事务、并发、状态机、权限校验这四件事全塞进了不到十张表的业务里。这套方案面向两类人:要交毕业设计或课程设计、需要一个能演示也能讲清结构的学生,以及接手一套老旧 ASP 代码、想把它改成可维护版本的开发者。下面从需求、结构、借还书主流程、后台权限,一路讲到排查和答辩验收,每一段都对应代码里真实存在的一两个文件,不是那种只讲概念不看实现的方案书。

2. 架构与选型:三层结构、六张核心表和三段式目录怎么摆

先把方向定死,后面写代码才不会反复推翻。图书馆管理系统的功能边界其实很清楚:图书编目、读者管理、借还书、查询统计、权限控制,五个模块撑死。真正决定这套代码好不好改的,是技术路线和表结构,而这两样东西一旦中期定了,后期再改成本极高。

2.1 经典 ASP、Web Forms、MVC 三条路线怎么选

拿到手的所谓"asp 图书馆管理代码",后缀可能是.asp,也可能是.aspx,这两者完全不是一个东西,答辩时说错会非常扣分。经典 ASP 用 VBScript 写在 HTML 里,<% %>中间直接拼 SQL 字符串,典型写法是"select * from books where id=" & request("id"),SQL 注入几乎是标配;ASP.NET Web Forms 是.aspx加.aspx.cs的代码后置模型,有服务端控件、事件回发和 ViewState;ASP.NET MVC 则是路由加控制器加视图,分层最干净。

三条路线的取舍建议如下:

路线上手成本答辩讲清难度适合的情况
经典 ASP(.asp)低,语法随意低,但容易被追问安全只要求跑通的老旧系统维护
Web Forms(.aspx)中,控件和事件要理解中,讲清页面生命周期即可课设、毕设,时间紧任务重
MVC / 前后端分离高,多一层接口契约高,要讲路由、模型绑定、鉴权有一定基础、想拿高分的毕设

我一般会推荐 Web Forms 加三层:GridView 直接支持分页排序,做完就能演示;代码后置天然把页面和逻辑分开了;部署到 IIS 上是复制粘贴加一个应用程序池的事。如果手上拿到的是.asp的老代码,第一件事不是加功能,而是把所有字符串拼接的 SQL 换成参数化查询,顺手把&拼接的请求参数全部走一遍过滤,这一改,安全分基本就保住了。

2.2 三段式目录:UI、业务、SQL 各归各的位置

分层这件事最怕做成"三层文件夹但是逻辑全在页面里"。判断标准很简单:业务层文件里出现SqlConnection,说明分层失败;数据层文件里出现"超期不能借"这种业务规则,同样说明分层失败。

LibrarySys/ ├── App_Code/ │ ├── Model/ # 实体类,一张表对应一个类,只放属性 │ │ ├── Book.cs │ │ ├── Reader.cs │ │ └── BorrowRecord.cs │ ├── DAL/ # 只做开连接、拼参数化 SQL、返回实体或 DataTable │ │ ├── BookDal.cs │ │ ├── CopyDal.cs │ │ └── BorrowDal.cs │ └── BLL/ # 只做业务规则判断,不出现任何数据库连接对象 │ ├── BorrowService.cs │ └── ReaderService.cs ├── Admin/ # 后台页面:图书编目、读者、借还、统计 │ └── BookList.aspx ├── Reader/ # 读者端:检索、我的借阅、续借 ├── Sql/ # 建表脚本、初始化数据、索引脚本 └── Web.config # 连接字符串、授权节点

这里要提醒一句:App_Code是 Web Site 项目模型的产物,代码会动态编译;如果你的项目是 Web Application 项目,对应的做法是拆成LibrarySys.Model、LibrarySys.DAL、LibrarySys.BLL三个类库,然后在主项目里引用。答辩时被问到"为什么这样分",一句话就够:借期从 30 天改成 21 天,我只改规则表的一行数据,不动任何一行页面代码。

2.3 六张核心表:图书和副本为什么必须拆开

这是整套系统里最容易被简化掉、又最容易被答辩追问的设计点。很多同学只建一张Books表,加个Stock字段表示还剩几本,结果借还书的记录没法对应到具体的某一本实体书上,丢了书也查不出是哪一本。

正确的做法是把"书目"和"副本"拆成两张表:Books一个 ISBN 一条,描述这本书的公共属性;BookCopies每一本实体书一条,条码是借还操作的唯一入口。

-- 书目表:描述“这本书是什么”,一个 ISBN 一条记录 CREATE TABLE Books ( BookId INT IDENTITY(1,1) PRIMARY KEY, ISBN VARCHAR(20) NOT NULL, Title NVARCHAR(200) NOT NULL, Author NVARCHAR(100) NULL, Publisher NVARCHAR(100) NULL, CategoryId INT NULL, Price DECIMAL(10,2) NULL, Status TINYINT NOT NULL DEFAULT 1 -- 1 可借阅 0 已下架 ); CREATE UNIQUE INDEX UX_Books_ISBN ON Books(ISBN); -- 副本表:描述“馆里有哪几本”,条码是借还操作的唯一入口 CREATE TABLE BookCopies ( CopyId INT IDENTITY(1,1) PRIMARY KEY, BookId INT NOT NULL REFERENCES Books(BookId), Barcode VARCHAR(32) NOT NULL, Location NVARCHAR(50) NULL, -- 馆藏位置,演示时很好用 State TINYINT NOT NULL DEFAULT 0 -- 0 在架 1 借出 2 破损 3 遗失 ); CREATE UNIQUE INDEX UX_Copies_Barcode ON BookCopies(Barcode); -- 读者表:借阅上限按读者类型给默认值,不要写死在代码里 CREATE TABLE Readers ( ReaderId INT IDENTITY(1,1) PRIMARY KEY, CardNo VARCHAR(20) NOT NULL UNIQUE, Name NVARCHAR(50) NOT NULL, ReaderType TINYINT NOT NULL DEFAULT 1, -- 1 学生 2 教师 MaxBorrow INT NOT NULL DEFAULT 5, Status TINYINT NOT NULL DEFAULT 1 -- 1 正常 0 挂失 -1 注销 ); -- 借阅记录表:DueDate 必须落库,不能每次用借书日期现算 CREATE TABLE BorrowRecords ( RecordId BIGINT IDENTITY(1,1) PRIMARY KEY, CopyId INT NOT NULL REFERENCES BookCopies(CopyId), ReaderId INT NOT NULL REFERENCES Readers(ReaderId), BorrowDate DATETIME NOT NULL, DueDate DATETIME NOT NULL, -- 应还日期,借出时一次算好写死 ReturnDate DATETIME NULL, RenewCount TINYINT NOT NULL DEFAULT 0, Fine DECIMAL(10,2) NOT NULL DEFAULT 0, Status TINYINT NOT NULL DEFAULT 0 -- 0 在借 1 已还 );

几个字段的用意值得在答辩时主动讲。DueDate落库而不是现算,是因为借期规则会变:三月份改成 21 天,不能让一月份借出去的书也按新规则算逾期,历史记录必须保持当时的约定。RecordId用BIGINT,因为借阅记录是只增不改的流水表,几年下来几十万条很正常。Status用TINYINT不用字符串,是为了建索引和做统计时不用处理大小写和空格。

另外还建议补两张小表:FineRules存每日罚款金额和封顶值,Reservations存预约排队。这两张表数据量极小,但能让"逾期罚款"和"预约到馆"这两个功能从"写死在代码里"变成"可配置",答辩时是明显的加分项。

3. 借还书主流程:四道校验、一个事务和逾期罚款的计算口径

借还书是整套系统的核心业务,也是唯一一个必须用事务收尾的地方。这一块写对了,剩下的增删改查都是体力活;写错了,演示时一次并发点击就能让你当场翻车。

3.1 借书前的四道校验,一道都不能省

借书不是"点一下就完事",它是一次带状态迁移的操作:副本从"在架"变成"借出",读者从"借了 N 本"变成"借了 N+1 本",借阅记录从无到有。这三次变更要么一起成功,要么一起失败,中间任何一个失败都必须回滚。

借出之前必须过四道校验,顺序也有讲究,从便宜的判断到昂贵的判断依次往下:

  1. 读者证状态正常,不是挂失、注销或冻结;
  2. 当前在借数量没有达到该读者的借阅上限;
  3. 该读者没有尚未归还的同一本书(按 BookId 判断,不是按条码);
  4. 目标副本确实在架,状态为 0。

第 3 条容易被漏掉,但它直接影响演示效果:不拦的话,同一个人可以把同一本书的三个副本全借走,逻辑上说得通,业务上很怪,导师一般都会问一句"这合理吗"。

// BorrowService.cs —— 借书主流程,四道校验与两次写入在同一个事务里 public BorrowResult Borrow(int readerId, string barcode) { using (var conn = new SqlConnection(Config.ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction(IsolationLevel.ReadCommitted)) { try { // 第一道:读者状态和在借数量 var reader = ReaderDal.GetById(conn, tran, readerId); if (reader == null || reader.Status != 1) return BorrowResult.Fail("读者证状态异常,无法借阅"); int onLoan = BorrowDal.CountOnLoan(conn, tran, readerId); if (onLoan >= reader.MaxBorrow) return BorrowResult.Fail("已达借阅上限 " + reader.MaxBorrow + " 本"); // 第二道:锁定副本行,防止两人同时借走同一本 var copy = CopyDal.GetForUpdate(conn, tran, barcode); if (copy == null || copy.State != 0) return BorrowResult.Fail("该副本不在架,无法借出"); // 第三道:同一读者不能重复借同一书目 if (BorrowDal.HasActiveByBook(conn, tran, readerId, copy.BookId)) return BorrowResult.Fail("该书尚未归还,不能重复借阅"); // 第四道:写记录 + 改副本状态,两件事必须同生共死 DateTime borrowDate = DateTime.Now; int loanDays = RuleDal.GetLoanDays(conn, tran, reader.ReaderType); BorrowDal.Insert(conn, tran, copy.CopyId, readerId, borrowDate, borrowDate.AddDays(loanDays)); CopyDal.SetState(conn, tran, copy.CopyId, 1); tran.Commit(); return BorrowResult.Ok(borrowDate.AddDays(loanDays)); } catch (Exception ex) { tran.Rollback(); Log.Error("借书失败 reader=" + readerId + " barcode=" + barcode, ex); return BorrowResult.Fail("系统繁忙,请稍后重试"); } } } }

IsolationLevel.ReadCommitted是 SQL Server 的默认隔离级别,够用且不会带来额外锁开销,除非你清楚知道自己在做什么,否则不要顺手改成Serializable。真正起并发保护作用的是下面这条 SQL 里的锁提示:

-- 取副本时加更新锁,行锁一直持有到事务结束,谁先拿到谁先借 SELECT CopyId, BookId, State FROM BookCopies WITH (UPDLOCK, ROWLOCK) WHERE Barcode = @Barcode;

UPDLOCK是更新锁,读的时候就把行占住,第二个事务再读同一行会阻塞,直到前一个事务提交或回滚;ROWLOCK把粒度限制在行上,避免锁升级把整张表锁住。两个参数合起来,就是"同一本书不会被两个人同时借走"的底层保证。

BorrowResult是一个简单的返回对象,包含Success布尔值和Message字符串,页面层只负责把它翻译成提示文字,不参与判断——这也是分层是否彻底的试金石:如果页面里出现if (onLoan >= 5),说明业务规则又漏回 UI 了。

3.2 还书、逾期判定与罚款:按自然日算,不按 24 小时算

还书的逻辑比借书短,但坑更深,因为逾期天数的计算口径一旦定错,后面所有报表、罚款、催还通知全是错的。

// 还书:先算逾期,再更新记录,最后把副本放回在架 public ReturnResult Return(string barcode) { using (var conn = new SqlConnection(Config.ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { var record = BorrowDal.GetActiveByBarcode(conn, tran, barcode); if (record == null) return ReturnResult.Fail("该条码没有在借记录"); DateTime returnDate = DateTime.Now; // 关键:两边都取 .Date 再相减,按自然日算,不按小时算 int overdueDays = (returnDate.Date - record.DueDate.Date).Days; decimal fine = 0m; if (overdueDays > 0) { var rule = RuleDal.GetFineRule(conn, tran, record.ReaderType); // 每天罚款 × 天数,但不超过规则里的封顶值 fine = Math.Min(overdueDays * rule.PerDay, rule.MaxFine); } BorrowDal.Close(conn, tran, record.RecordId, returnDate, fine); CopyDal.SetState(conn, tran, record.CopyId, 0); tran.Commit(); return ReturnResult.Ok(overdueDays, fine); } } }

为什么强调DateTime.Today而不是TimeSpan.TotalDays?因为借书时间是下午三点,应还日期写的是AddDays(30),同样是下午三点;如果读者在第 31 天上午十点来还,TotalDays算出来是30.79,取整得 30,判定为"没逾期";而按自然日算,第 31 天就是逾期 1 天。两种口径差一天,罚款差一笔,读者投诉就来了。用.Date把时间部分抹掉再相减,是最不容易出错的写法。

封顶值MaxFine的意义在演示时特别直观:一本 30 块钱的书,逾期半年按每天 0.2 元罚出来是 36 元,比书还贵,业务上不合理。规则表里配一个 20 元的封顶,既有逻辑又能讲清"为什么罚款要有上限"。

罚款的去向建议两处都记:BorrowRecords.Fine存这一笔的明细,读者表旁边再挂一张缴费或余额记录,答辩时能解释"明细可追溯,余额可结算"。

3.3 续借与预约:容易被砍掉、又容易被追问的两个功能

续借的逻辑不复杂,但条件要卡死:当前记录未归还、未逾期、续借次数没超过上限(一般 1 次)、并且这本书没有其他读者在排队预约。最后一条最容易被忘,忘了就会出现"我预约了,结果被人续借走了"的尴尬。

-- 续借:一次性判断四个条件,返回受影响行数,0 表示不满足 UPDATE BorrowRecords SET DueDate = DATEADD(DAY, 30, DueDate), RenewCount = RenewCount + 1 WHERE RecordId = @RecordId AND ReturnDate IS NULL AND DueDate >= GETDATE() AND RenewCount < 1 AND NOT EXISTS ( SELECT 1 FROM Reservations r JOIN BookCopies c ON c.BookId = r.BookId WHERE c.CopyId = BorrowRecords.CopyId AND r.Status = 0 );

把条件全塞进WHERE里,比先查再判断再更新安全得多:判断和更新是同一条语句,中间没有时间窗,不会出现"判断时还能续,写进去时已经被预约"的情况。返回值是受影响行数,0 就提示"不满足续借条件",页面不用自己去拼错误原因。

预约功能则是在还书那一刻触发的。某人还书之后,系统把这本书最早的一条预约置为"待到馆"状态:

-- 还书成功后台执行:把该书最早的一条预约标记为待到馆 UPDATE Reservations SET Status = 1, NotifyTime = GETDATE() WHERE ReservationId = ( SELECT TOP (1) ReservationId FROM Reservations WHERE BookId = @BookId AND Status = 0 ORDER BY CreateTime ASC );

这里不能写成UPDATE TOP (1) ... ORDER BY,因为UPDATE TOP不接受ORDER BY,写出来会直接语法报错——这属于典型的"看别人博客抄一半"翻车点。用子查询先取出唯一的主键再更新,语义明确,也不会有歧义。

4. 后台与权限:编目入库、读者管理和并发下的库存一致性

后台是演示时最容易加分也最容易出事的地方。编目入库考验的是数据一致性,读者管理考验的是表单校验,权限考验的是"你到底有没有真的做过安全",三者加起来正好覆盖答辩常问的那几个问题。

4.1 图书编目:ISBN 查重之后批量生成副本

入库流程我一般拆成三步:扫 ISBN 查重、填书目信息、按数量生成副本。查重不是为了好看,是为了避免同一本书被录入两次,导致检索结果里出现重复条目。

副本条码建议用规则的字符串,而不是直接用自增的CopyId:前缀 + 入库年份 + 四位流水,比如TS2024000137。好处是条码本身能看出入库批次,标签打印机打出来也整齐;坏处是需要一个流水号生成逻辑,但这个逻辑用一个SELECT MAX加事务就能解决。

// 批量生成副本:数量填 5 就插 5 条,任一条失败整批回滚 public int AddCopies(int bookId, int count, string prefix) { using (var conn = new SqlConnection(Config.ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 取当前最大流水号,事务内完成,避免并发重号 int seq = CopyDal.GetMaxSeq(conn, tran, prefix); for (int i = 1; i <= count; i++) { string barcode = prefix + (seq + i).ToString("D4"); CopyDal.Insert(conn, tran, bookId, barcode, "A区-3排"); } tran.Commit(); return count; } catch { tran.Rollback(); throw; } } } }

参数count建议在页面上限死 1 到 20,prefix从分类代码带过来。GetMaxSeq里必须用WITH (UPDLOCK)把当前最大号的行锁住,否则两个管理员同时录入同一批书,会生成两组一模一样的条码,而条码上有唯一索引,第二个人的整批插入会直接失败——失败比重复好,但体验很差,加锁能从源头避免。

4.2 权限:菜单藏起来不等于接口挡住了

后台权限最常见的错误做法是:登录后根据角色动态隐藏菜单项。看着挺像回事,实际上任何人拿到地址栏直接敲/Admin/ReaderList.aspx照样能进。原因很简单,菜单只是 HTML,服务端根本没校验。

正确做法是在页面基类里做统一拦截,所有后台页面继承这个基类:

// AdminBasePage.cs —— 所有后台页面的父类,在 OnInit 里做登录与功能权限校验 public class AdminBasePage : System.Web.UI.Page { protected override void OnInit(EventArgs e) { base.OnInit(e); // 1) 登录态校验,未登录直接跳登录页并带上回跳地址 var user = Session["AdminUser"] as AdminUser; if (user == null) { Response.Redirect("~/Login.aspx?returnUrl=" + Server.UrlEncode(Request.RawUrl)); return; } // 2) 功能权限校验:用页面类名当功能码,去权限表里查 string pageCode = GetType().Name.Replace("_aspx", "").ToLower(); if (!PermissionService.Has(user.RoleId, pageCode)) { Response.StatusCode = 403; Response.End(); return; } } }

两个细节值得讲。第一,校验放在OnInit而不是Page_Load,因为Page_Load执行时回发事件(比如删除按钮的Click)已经开始处理了,等你判断完权限,删除动作可能已经执行;OnInit是页面生命周期最早的稳定节点,挡得住。第二,功能码用页面类名生成,新增一个页面只要在权限表里补一行数据就能授权,不用改代码——这也是答辩时"权限系统可扩展"的实证。

如果不想写基类,Web.config的location节点也能挡一层,作为兜底:

<location path="Admin"> <system.web> <authorization> <deny users="?"/> </authorization> </system.web> </location>

但要注意这只是"未登录不能进",做不了"登录了但不是这个角色也不能进",所以两层配合着用才完整。

4.3 并发下的库存一致性:别在书目表上挂可用数量

很多现成代码喜欢在Books表加一个AvailableCount字段,借出减一、归还加一,看起来省掉了一次关联查询。我不建议这么做,原因是这个字段和BookCopies.State是同一份事实的两个副本,一旦某次事务没写全,两边就对不上,而且对不上之后没人知道该信哪个。

更稳的做法是:可用数量永远靠COUNT实时算,或者放在缓存里定时刷新,绝不在主表里冗余存储。

-- 检索时直接算可借数量,索引落在 BookCopies(BookId, State) 上,速度够用 SELECT b.BookId, b.Title, b.Author, COUNT(c.CopyId) AS TotalCopies, SUM(CASE WHEN c.State = 0 THEN 1 ELSE 0 END) AS AvailableCopies FROM Books b LEFT JOIN BookCopies c ON c.BookId = b.BookId WHERE b.Status = 1 AND b.Title LIKE @Keyword GROUP BY b.BookId, b.Title, b.Author;

配套的索引必须建:CREATE INDEX IX_Copies_BookState ON BookCopies(BookId, State);。图书馆的副本总量撑死几万条,这个查询在普通笔记本上都是毫秒级,完全没必要为了省这点开销引入一个可能不一致的冗余字段。真要优化,那也是上缓存的事,不是改表结构的事。

最后一道保险是数据库层面的唯一约束,防止应用层判断漏掉时写出重复数据:

-- 同一读者对同一副本,在未归还状态下只能有一条记录 CREATE UNIQUE INDEX UX_Borrow_Active ON BorrowRecords(ReaderId, CopyId) WHERE ReturnDate IS NULL;

这是 SQL Server 2008 之后支持的筛选索引,只对满足条件的行建唯一约束。如果部署环境的数据库版本更低,就退化成在业务层加UPDLOCK判断,或者用触发器兜底,效果差一点但也能用。

5. 常见问题排查:借还书模块上线后最容易翻车的五个点

这一章列的五个问题,基本覆盖了图书馆管理系统从本地跑通到真正交上去之间,百分之八十的返工。每一条我都按"看到什么现象、为什么会这样、怎么改"来说,照着对一遍能省掉不少返工时间。

5.1 借书提示成功,库存却没减

现象:点击借书,页面弹出"借阅成功",但图书列表里的可借数量纹丝不动,重新进详情页才正常。

原因:写借阅记录和改副本状态被拆成了两次独立的数据库操作,第一次成功第二次失败,或者第二次压根没写。更隐蔽的一种是页面用了UpdatePanel,局部刷新把旧数据又渲染回来了,看着像没变。

解决:把这两步放进同一个SqlTransaction,任何一步抛异常就整体回滚,代码就是 3.1 节里那段。同时确认列表页没有开OutputCache,或者缓存时间设得极短,否则刷新出来的还是旧数据。

5.2 同一个人借出了两条一模一样的记录

现象:测试时手快连点两下借书按钮,或者两个人同时帮同一个读者操作,结果借阅记录里出现两条相同的CopyId,副本状态还是"借出"。

原因:校验和写入之间存在时间窗。两个请求几乎同时进来,各自查了一遍"是否已借",都查到没有,然后都执行了插入。

解决:三层一起上。数据库加 4.3 节里的筛选唯一索引,从根上杜绝重复;业务层取副本时加UPDLOCK,把并发请求串行化;前端按钮点击后立刻disabled,防止用户误触。前两条是必须的,第三条是体验优化。

5.3 逾期天数总是差一天

现象:明明昨天到期,今天来还,系统显示"逾期 0 天,罚款 0 元"。

原因:用(DateTime.Now - DueDate).Days计算。DueDate是BorrowDate.AddDays(30)算出来的,带着借书那一刻的时分秒;如果借书是下午三点,第二天上午十点来还,时间差是30 天 19 小时,.Days取整还是 30,判定不逾期。

解决:两边都取.Date再相减,(DateTime.Today - DueDate.Date).Days。改完之后要补一组测试用例:到期当天还、到期次日还、跨月还、跨年还,四个用例全过才算修好。

5.4 部署到 IIS 后中文变问号,日期全变成 1900-01-01

现象:本地 VS 里跑得好好的,发布到服务器之后,页面上的"张三"变成"??",日期显示1900-01-01。

原因:两个地方没配。一是Web.config里没声明全局编码,服务器默认按系统区域解析;二是连接字符串里没指定字符集,数据库的排序规则和程序端对不上,中文写进去就是乱码。日期变成 1900 通常是空值被强转成DateTime的默认值,说明查询返回了NULL但代码里没判空。

解决:Web.config的globalization节点显式声明requestEncoding和responseEncoding为utf-8;建库时排序规则选支持中文的(Chinese_PRC_CI_AS或更新版本的对应规则);读取DateTime字段时统一走reader.IsDBNull(i) ? (DateTime?)null : reader.GetDateTime(i),不要直接强转。

5.5 菜单藏起来了,接口还能直接调

现象:用低权限账号登录,菜单里看不到"读者管理",但地址栏输入/Admin/ReaderList.aspx照样打开,还能删人。

原因:权限只做在Menu控件的可见性上,服务端没有任何校验。

解决:所有后台页面继承统一的基类,在OnInit里做登录态和功能权限双重判断(见 4.2 节),必要时再加Web.config的location授权兜底。改完之后一定要用低权限账号手工敲三次地址栏,这是验收时最容易被现场抽查的一项。

6. 答辩与验收:一张演示流程表和八条边界用例怎么用

验收这件事,最忌讳的是打开系统随口点两下。评委看不到重点,你自己也容易漏功能。我习惯提前准备一张演示流程表,从上到下走一条完整链路,每一步都对应一个能讲的技术点。

顺序操作预期结果对应的技术点
1管理员登录后台进入首页,菜单按角色显示登录态与角色权限
2新增一本图书,生成 3 个副本列表出现 3 条在架副本事务与批量插入
3读者登录,检索该书显示可借 3 本关联查询与索引
4借出其中一本可借变 2,借阅记录新增一条借书四道校验
5再借同一本书的另一副本提示不可重复借阅业务规则拦截
6后台把还书日期改到过去,还书显示逾期天数与罚款自然日计算与封顶
7用低权限账号访问后台地址返回 403服务端权限校验
8同一账号两个浏览器同时点借书只有一条成功锁与唯一索引

表下面是边界用例,不一定都演示,但自己必须跑一遍,答辩被追问时才答得上来:借满上限再借一本、借一本已经被借走的副本、到期当天还书、挂失的读者证借书、条码输入不存在的值、续借两次、预约后被人续借、后台并发录入同一批书。

再补一个小技巧:把数据库脚本和初始化数据单独放在一个Sql目录,答辩前十分钟现场重建一次库。这一步看着多余,实际上能保证你演示用的数据和代码里的字段完全对得上,不会出现"我这里明明有数据"的尴尬。

从那以后我每次交方案之前,都会把借书按钮在两个浏览器里同时点一遍,再拿一条恰好到期的记录去还一次,最后用最低权限账号把后台地址挨个敲一遍。这三件事花不到十分钟,但能挡掉绝大多数现场翻车。希望帮到你。

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

RELATED READING

延伸阅读

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