我在某社区做志愿活动的时候,第一次看见了爱心书屋的运营现场。楼下那个旧书架摆了几百本捐赠书,借阅信息全部记在纸质登记本上,书的去向全凭志愿者脑补。后来我决定把整套流程数字化,用SpringBoot搭了一个公益图书借阅平台,把捐书、上架、借书、还书、逾期、捐赠公示全打通,也正是毕设里常说的社区共享书屋数字化运营系统。这个题目看起来就是标准CRUD,但真正做进去之后会发现,状态流转、并发控制、事务边界、定时任务这些东西一个都躲不掉,很值得写一篇完整实操记录。
1. 爱心书屋的需求真相:一本登记册暴露出的管理盲区
我一开始和几个同学讨论题目时,大家的第一反应都是:借书还不简单?建三张表,用户表、图书表、借阅记录表,剩下时间拿来刷题。但真去社区书屋蹲了两天现场,想法立刻就变了。实体书和电商商品完全是两个物种,每本书都有独立身份、有捐赠人、有架位、有磨损状态,而且它的流转不是一次性买卖,是"借出去-还回来"无限循环。登记册里那些涂改过期的记录,背后全是业务漏洞。
1.1 每本实体书都是一个独立的库存单元
很多新手做图书系统时,会把“书名”当成商品,在图书表里放一个库存数量字段。这个思路放到共享书屋场景里是行不通的。捐赠者捐的是他手头那一本具体的书,不是抽象的书名;同一套《平凡的世界》可能有五个人各捐了一本,这五本在架上就是五个独立个体,各有各的架位码,也各自有自己的状态。
因为这一点,book表设计成“一行代表一本实体书”是非常关键的决策。每本书的状态至少包括:待审核、在库、借出、预占、下架、破损报废。字段设计大概是:
| 字段 | 含义 | 示例 |
|---|---|---|
| shelf_code | 架位编码 | A-03-02,方便志愿者找书 |
| donor_id | 捐赠人用户ID | 用于捐赠公示溯源 |
| status | 当前图书状态 | ON_SHELF / LENT / RESERVED |
| version | 乐观锁版本号 | 并发更新时校验使用 |
我见过有人把书的状态放在借阅记录里而不是图书身上,带来的直接后果就是“查当前架上有什么书”这种基础功能都要join好几张表,而且经常查出脏数据。书的状态必须属于书本身,借阅记录只是它的流转日志。
1.2 捐赠入库的审核与上架流程
爱心书屋区别于普通书店的一个重要点,就是书的来源是捐赠。捐书流程在纸质登记时代基本是“放下就走”,但数字化之后至少要拆成提交、审核、上架三个环节。捐赠者在页面填写图书信息,系统生成一条donation_record,状态是待审核;志愿者确认书籍品相、分类、录入架位码之后,审核通过,系统自动在book表里插入一条实体书记录,同时把donation_record状态改成已上架。
为什么要单独拆一张捐赠表而不直接把捐赠信息塞进book表?因为审核过程中会有退回操作,还要记录审核意见,比如“品相太差不收”“少儿不宜不收”。这些信息属于捐赠流程本身,不属于书的基本资料。多一张表换来的是流程可追溯。
1.3 借阅规则要提前想清楚,别在写代码时改来改去
我在项目一开始就把借阅规则定成了可配置项,放在sys_config表里,而不是写死在代码里:
- 每个读者最多同时借2本
- 默认借期30天,可以续借1次,续借15天
- 逾期每天扣1个信用分
- 信用分低于60分暂停借阅资格
- 按时归还奖励1个信用分,上限100分
这些数字看着简单,但它们会直接决定借还接口里有多少if判断、定时任务里怎么算逾期、积分模块怎么加减。后来社区志愿者跟我说想把可借本数改成3本,管理员在后台配置页改个数字就生效,不用重新部署,这个细节在答辩时也很加分。
2. 整体架构与数据模型:给社区书屋设计一套可落地的状态机
技术选型上我没有搞什么花活,毕设项目讲究的是稳定、成熟、能说清楚。后端选了SpringBoot 2.7.x,ORM用的MyBatis-Plus,数据库MySQL 8,前端Vue3 + Element Plus。Redis没做硬性依赖,只把登录token随手放了进去,这样就算学校服务器配置一般,跑起来也不会有压力。Spring Security + JWT做登录鉴权,密码用BCrypt加密存储,这些属于毕设项目的基础配置,写进去基本不会出错。
2.1 用状态机来管理图书和借阅记录,而不是散落的if嵌套
如果状态管理靠每个接口自己用if判断,后期加一个“预约”功能时,你会发现所有涉及状态的接口都要翻出来改一遍。我第一版就是这么干的,结果改到吐。正确的做法是先定义清楚状态机,然后在代码里用枚举或者常量表统一管理。
图书状态流转:
- 待审核:捐赠提交后的初始状态,审核通过进入在库
- 在库:可被借阅,也是归还后的默认落点
- 借出:被某位读者借走
- 预占:有读者预约了这本书,归还后直接进入预约者名下,不回到公共在库池
- 下架/报废:书籍损坏、丢失或管理员主动下架
借阅记录状态流转对应的则是:借阅中、已归还、已逾期、逾期已归还、违约。续借不算状态变化,它只是把due_time往后推,同时renew_count加1,这一点很多人会误解成需要新状态。
2.2 核心表结构设计
整个系统我用到的关键表大概六张:sys_user(用户)、book(实体书)、borrow_record(借阅记录)、donation_record(捐赠记录)、reserve_record(预约记录)、notice(公告)。业务查询高频集中在borrow_record和book上,所以索引要提前留好。
CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, book_id BIGINT NOT NULL, borrow_time DATETIME NOT NULL, due_time DATETIME NOT NULL, return_time DATETIME NULL, renew_count INT DEFAULT 0, status VARCHAR(20) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_status (user_id, status), INDEX idx_book_status (book_id, status) );这里特别要说明:borrow_record里必须冗余user_id和book_id,查询“某个读者当前借了哪几本”“某本书现在在谁手上”才能走索引快速返回。不要指望通过书的状态去join出历史记录,查询性能会很差。
2.3 为什么状态机设计比接口数量重要
业务规则如果不集中管理,借阅接口查一次状态、归还接口又查一次状态,两边逻辑稍微不一致就会出现bug。比如借阅时允许借在库书,归还时更新状态却忘了判断有没有人预约,结果预约者永远等不到书。把状态机定义清楚之后,所有状态迁移都收敛到几个统一方法里执行,改规则只改一处。这个设计习惯对于毕设答辩来说,是一个很拿得出手的亮点。
3. 核心功能实现:借阅、归还、续借背后的Java代码细节
下面这部分我直接讲实现。先说明,前端页面我不细讲,核心价值都在后端的接口设计和数据一致性上。借阅流程做的不是“插入一条记录”这么简单,它至少要完成五个步骤。
3.1 借阅接口:校验、加锁、写记录、改状态
@Transactional @Override public Result borrow(Long userId, Long bookId) { User user = userMapper.selectById(userId); if (user == null || user.getStatus() != 1) { return Result.fail("用户不存在或已被禁用"); } if (user.getCredit() < 60) { return Result.fail("信用分不足,无法借阅"); } long activeCount = borrowRecordMapper.countActiveByUserId(userId); if (activeCount >= user.getMaxBorrowNum()) { return Result.fail("已达借阅上限"); } // 注意这里使用了行锁 Book book = bookMapper.selectByIdForUpdate(bookId); if (book == null || !"ON_SHELF".equals(book.getStatus())) { return Result.fail("当前书籍不可借阅"); } BorrowRecord record = new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setBorrowTime(new Date()); record.setDueTime(DateUtil.offsetDay(new Date(), user.getBorrowDays())); record.setStatus("BORROWING"); borrowRecordMapper.insert(record); bookMapper.updateStatus(bookId, "LENT"); return Result.ok(record); }selectByIdForUpdate是关键,它会对book表中这一行数据加行级排他锁。两个用户同时借同一本书时,后进入的事务必须等前一个事务提交或回滚,才能读到最新状态,这样就从数据库层面解决了超借问题。这里有个前提:方法上必须有@Transactional,行锁要等事务提交才释放,如果方法没有事务,锁会在单条SQL执行完就释放,等于白写。
还有个小细节,countActiveByUserId的SQL一定要加状态过滤,只统计BORROWING和OVERDUE的记录,如果统计里混进已归还记录,借阅上限就形同虚设了。
3.2 归还接口:逾期计算与积分变动的联动
归还时最核心的逻辑是算逾期天数。应还日期是6月1日,实际还书是6月10日,那就是逾期9天。按规则每天扣1分,所以要扣9分。如果按时归还,奖励1分。这个计算要在事务里跟状态更新一起完成,避免出现记录状态改了但分没扣的情况。
归还流程大致是:
- 根据recordId和userId查询借阅记录,状态必须是BORROWING或OVERDUE
- 计算逾期天数
- 更新用户信用分,公式为:新积分 = 当前积分 - 逾期天数 + 按时奖励
- 更新借阅记录状态为RETURNED、写入return_time
- 判断该书有没有预约记录:有则把book状态置为RESERVED并标记最早的预约人可借,没有则置回ON_SHELF
步骤5最容易被忽略。你想想,一本预约状态的书如果被无脑放回在库池,预约用户第一时间去借,结果发现架上根本没有,因为已经别人又被借走了,这就是状态机不完整导致的线上bug。
3.3 续借与预约:两个容易出逻辑漏洞的点
续借看起来只是把due_time往后挪,但设防条件必须写全:当前状态必须是BORROWING,不能已逾期;renew_count必须小于允许续借次数;这本书当前不能有预约记录。三个条件全部满足才允许续借,然后一次性更新due_time和renew_count,别分成两次update,减少中间态的暴露时间。
预约功能则要保证同一用户对同一本书不能重复预约,否则会占住队列位置。我用的reserve_record表,每次插入前先查是否已有status为WAITING的记录。取预约用户时按照create_time升序排,也就是先到先得。
3.4 别小看索引,借阅记录表会随着使用越来越臃肿
书屋跑起来之后,借还记录是只增不减的。就算只有几百个用户,一年下来借阅记录也能轻松上万条。所以在设计表结构时就要把高频查询字段的索引建好。我不止一次见过因为没有索引,首页“当前借出书籍列表”查询直接慢到秒级的情况。给borrow_record加上(user_id, status)联合索引,给book加上status索引,查询性能就能压到毫秒级,这点成本几乎为零。
4. 完成度与稳定性:并发借阅、事务失效、定时任务三个实战坑
这一节是全文最能帮你少走弯路的部分,因为这些问题我在开发阶段全踩过。每一个坑的排查链路我都尽量复述出来,比直接给结论有用得多。
4.1 同一本书被两个人同时借走的并发问题
第一版代码我没有加selectByIdForUpdate,只做了“先查询status,再插入记录,再更新状态”的顺序操作。自测时一切正常,因为单人操作根本不会触发并发,直到我用两个浏览器同时登录测试才发现,两个请求都读到了ON_SHELF状态,都插入了borrow_record,最后一本书被借给了两个人。
排查链路是这样的:先在MySQL日志里看两条请求的执行顺序,确认没有等待锁的迹象;再检查service方法上有没有@Transactional,确实有;最后看SQL,发现图书状态查询用的是普通selectById,压根没走锁机制。根因清楚了,修复就简单,把查询改成SELECT ... FOR UPDATE,再利用status条件做更新兜底:
UPDATE book SET status = 'LENT' WHERE id = #{bookId} AND status = 'ON_SHELF'如果update返回影响行数为0,说明这本书状态已经被别人改掉了,事务直接回滚。行锁加条件更新双保险,基本就不会出现超借了。
4.2 @Transactional不生效:同类的内部方法调用
这个坑更隐蔽。当时我把借阅和还书都放在同一个service类里,为了复用逻辑,直接在borrow方法里调用同类的另一个私有方法。结果测试时发现,一旦内部方法抛异常,前面的写操作居然没有回滚。
后来查了Spring的文档才反应过来,@Transactional是基于AOP动态代理实现的,通过this.xxx()调用的同类方法,不会经过代理对象,事务注解自然失效。这就是经典的自调用问题。
解决方案有三种:一是把被调用的方法拆到另一个service类里,通过Spring注入后调用;二是注入自身代理对象;三是使用TransactionTemplate编程式事务。我最后选的是第三种,因为它最直白,让人一眼就能看出事务边界:
transactionTemplate.execute(status -> { // 多个写操作 borrowRecordMapper.insert(record); bookMapper.updateStatus(bookId, "LENT"); return Boolean.TRUE; });这个教训我一直记着,后来看项目代码时,凡是事务失效问题,第一反应就去查是不是同一个类内部调用。
4.3 定时扫描逾期记录的幂等设计
逾期判断不能只靠用户还书时触发,万一有人几个月不还呢?所以系统里加了一个每天凌晨2点执行的定时任务,扫描所有BORROWING状态且due_time早于当前时间的记录,把它们改成OVERDUE,同时扣除信用分。
这里最怕的是任务在扫描过程中重启,导致同一条记录被处理两遍。我的处理方式是让状态更新本身带条件:
UPDATE borrow_record SET status = 'OVERDUE' WHERE id = #{recordId} AND status = 'BORROWING' AND due_time < NOW()update返回行数为1才执行扣分,返回0说明已经被别的任务处理过了,直接跳过。这种幂等写法比“先查再改”可靠得多,即使定时任务重复执行也不会重复扣分。
4.4 MyBatis-Plus默认行为带来的脏数据
MyBatis-Plus确实好用,但几个默认行为一定要提前知道。updateById默认不会更新null字段,这本来是保护机制,但如果你想主动把某个字段清空,比如把图书的架位码置空,直接传一个setShelfCode(null)根本不会生效。必须用UpdateWrapper显式set。
另一个坑是@TableField(fill = FieldFill.INSERT)自动填充字段,我在create_time上配置了自动填充,结果业务代码里又手写了setCreateTime,两边冲突,日志里出现了奇怪的覆盖行为。后来统一原则:填充字段只在元对象处理器里处理,业务代码一律不碰。
5. 社区运营视角:从"能借书"到"愿意持续用"的数字细节
很多人做管理系统止步于CRUD,但爱心书屋这种公益项目,真正考验人的是怎么让志愿者愿意用、让捐赠者信得过、让读者觉得方便。所以我在功能清单里额外加了几个运营向的模块。
5.1 数据看板:让志愿者一眼看清全局
管理端首页我放了一个数据看板,包括当前在库图书数量、借出数量、今日归还数、待审核捐赠数、预约未处理数,下面再用ECharts画两张图:近30天借阅趋势和分类借阅占比。
热榜的实现很简单,就是对借阅记录按图书ID分组统计:
SELECT b.id, b.title, COUNT(r.id) AS borrow_count FROM borrow_record r JOIN book b ON r.book_id = b.id GROUP BY r.book_id ORDER BY borrow_count DESC LIMIT 10趋势图则用DATE_FORMAT(borrow_time, '%Y-%m-%d')做分组统计。这些查询对MySQL来说都是毫秒级的,但带来的运营价值却很大,志愿者能看出哪个分类最受欢迎、最近活动效果怎么样,而这些在纸质登记时代根本无从谈起。
5.2 捐赠公示:公益透明感是信任的基础
爱心书屋的性质决定了它必须让捐赠者看到自己的书到底去了哪。所以我在前台做了一个捐赠公示页面,每一本捐赠书都会展示捐赠人昵称、捐赠时间、图书当前状态以及累计借阅次数。状态是动态算出来的,显示“已被××借走”还是“存放在A区03架”一目了然。
这个模块技术上没有难度,难的是理念:公益类系统一定要把“透明度”当成功能来做,而不是当作锦上添花。答辩时如果只讲技术,评审老师可能觉得一般,但把“为什么做捐赠公示”“数据如何联动”讲清楚,项目立意就上来了。
5.3 公告与阅读活动:让平台不止是借还工具
社区书屋的定位是社区公共文化空间,所以我在系统里还加了公告管理和阅读活动报名。志愿者可以发布周末读书会的公告,并关联一个活动报名表单,读者在前台报名。
活动模块的表结构很简单:notice表里加一个activity_flag字段区分普通公告和活动,再建一张activity_signup表记录报名人和活动ID。做的时候别贪大,能完成“发布-展示-报名-取消报名”闭环就够了,这已经是一个很完整的社区运营小场景。
6. 毕设防坑指南:我从这个项目里拿到的几条实在经验
做完整个项目,我最大的感受是:毕设不一定要功能多,但一定要让你踩过的坑有说头。如果你准备选类似题,或者已经开始写了,下面几条经验应该能帮你省不少事。
6.1 先画状态机再建表,至少省一周改表时间
我是先建了表才去理状态流的,结果后期加预约功能时,表的字段改了三回,接口逻辑也大面积返工。你写代码之前,先把图书状态和借阅记录状态的所有流转路径写在纸上,甚至把每个状态迁移对应的接口列出来,再动手写建表SQL。这件事花不了一下午,但能让你后面开发顺畅很多。
6.2 准备一套高质量的演示数据
系统写完除了自测,还要准备一套像样的演示数据。至少要模拟30本图书、分布在5个分类里,3个读者账号,几十条借阅记录让热榜和趋势图有东西可画,再故意制造一条逾期未还记录,让逾期定时任务的效果可以现场演示。
演示的时候别只点菜单,要讲一个完整故事:先看数据看板,然后模拟捐赠者捐一本书,志愿者审核上架,读者借走,再到归还,回看数据看板变化。这一套走下来,功能点覆盖得七七八八,也比零散点页面有逻辑得多。
6.3 答辩时讲透一个难点,胜过罗列十个功能
选这个题目的同学普遍会担心项目不够难。实际上,只要做深一个点,完全够讲。我最建议讲的就是借阅并发控制:为什么用行锁、锁在什么范围生效、事务边界在哪、如果不用锁会出现什么后果。这个点串着数据库原理和Spring事务机制,技术含量比做十个简单功能高得多。我当时的经验是,把问题用十分钟讲透,给评审留下的印象远好过把每个菜单念一遍。
6.4 两个值得继续扩展的方向
如果做完还有时间,我建议从两个方向往上延伸。一个是移动端接入,把借书功能搬到手机上,扫码借书、预约通知推送,体验会提升一大截;另一个是基于借阅记录做简单的推荐,按分类偏好给用户推书,数据基础已经有了,加一个推荐接口并不复杂。两个方向都能让这个公益图书借阅平台更有完整度,论文后续也有东西可写。
最后说一句实在话,爱心书屋管理系统表面上是人人都会做的图书CRUD,但只要你把它当成一个真实要运营的社区产品,状态并发这些硬骨头很快就暴露出来,啃下来之后,你对SpringBoot的理解绝对比刷一百道面经题都深刻。