疫情期间做图书馆管理系统,我最大的感受是:传统图书管理软件的业务逻辑并不复杂,真正复杂的是“限流、预约、健康核验、追溯”这一套突发卫生事件下的流程组合。如果你正在找SpringBoot+Vue全栈项目的完整方案,或者学校课设、个人毕设想做一个既有常规借阅管理、又能体现防疫业务场景的系统,这篇文章应该能给你一条可以直接落地的路线。我基于SpringBoot、Vue、MyBatis、MySQL这四个核心选型,把整个“疫情下图书馆管理系统”从表结构设计到前后端联调,再到部署上线完整走了一遍,文中的核心代码都是实际能跑的,表结构和接口设计也给你可以直接参考的版本。不管你是刚开始搭建还是已经写到一半卡在某个环节,下面的内容应该都有对得上号的部分。
1. 项目背景与整体设计思路
1.1 疫情下图书馆管理的核心痛点
传统图书馆管理系统处理的是“书和人”的关系:读者借书、还书、续借、预约,管理员维护图书、处理逾期。这套逻辑很成熟,随便一套开源图书管理系统都能覆盖。但把场景放到疫情背景下,管理需求一下子多出来好几层:
一是入馆人员控制。图书馆属于密闭公共场所,需要实时掌握馆内人数,超过阈值就不能再放人进去,这就需要独立的预约和限流机制。
二是到馆信息登记。读者进入图书馆需要记录到馆时间和健康状态,不能靠人工登记本,那样速度慢、出错率高、信息还不方便事后追溯。
三是借阅流程的防护要求。归还的图书不能直接上架,需要经过消毒和隔离处理,这个操作环节必须被记录到系统里,方便管理员查询某本书的消毒状态和次数。
四是信息通知。政策调整、开馆闭馆时间变化、预约规则变化,都需要第一时间触达读者,传统系统里的静态公告模块往往不够灵活。
所以我在设计这个系统的时候,没有只做一张“图书管理”的皮,而是把系统明确拆成两条业务线:一条是常规的图书馆业务链路——读者管理、图书管理、借阅归还;另一条是疫情管控链路——入馆预约、到馆登记、图书消毒、在馆人数统计。两条链路共用同一套用户体系和权限体系,但在数据层和接口层上是独立设计的,这样任何一条链路出问题,不会拖垮另一条。
1.2 系统角色与功能模块划分
系统的用户角色我设计了三种:读者、图书管理员、系统管理员。
读者端能看到的是:书目检索、个人借阅列表、逾期提醒、入馆预约、预约记录、个人健康信息填写。
图书管理员端处理的是日常业务:图书录入与编辑、借书操作、还书操作、图书消毒登记、预约审核、当日到馆记录核验。
系统管理员端则负责全局配置:用户管理、角色权限分配、馆内容量参数设置、每日预约时段配置、公告发布、数据统计与导出。
整个系统在前端分为两个大的界面体系:面向读者的门户界面,和面向管理员(图书管理员、系统管理员)的后台管理界面。这个设计听起来是常规操作,但项目里很多细节是从这里延伸出去的,比如同一套Token怎么在两种界面体系中共用、权限拦截怎么在前端路由和后端接口两层同时生效,这些实际落地的时候有不少值得注意的地方。
1.3 为什么采用了前后端分离
有人可能会说,这种规模的管理系统,用传统的Thymeleaf模板引擎一把梭不是更快?确实,如果是纯课程设计,完全可以用单体服务端渲染,代码量更少。但考虑到这个系统的实际使用场景——管理员在馆内用后台PC操作,读者在手机上用微信内置浏览器打开做预约和扫码核验——前后端分离的好处就很明显了。
读者端和管理端的界面风格完全不同、使用设备完全不同、更新频率也不同,如果耦合在同一个服务端模板项目里,每次改动都有风险。用Vue做两个独立的前端工程,后端只提供JSON API,前端和后端各自独立部署,读者端页面更新不会碰管理端,管理端加权限功能也不会影响读者端。实际开发过程中,这个决策节省了大量联调时间。
2. 技术选型:SpringBoot + Vue + MyBatis + MySQL 的最佳组合
2.1 为什么是 SpringBoot
这个项目选择SpringBoot,核心原因是它对中小型管理系统太友好了。Spring生态本身是Java后端的事实标准,但传统Spring XML配置的繁琐程度很多人都有体会——配置一个数据源、配置事务、配置AOP,那一套写下来比业务代码还长。SpringBoot把这些基础设施的东西做成了自动配置,项目启动后几分钟就能搭起一个能跑的Web服务。
疫情图书馆管理系统本质上是典型的CRUD加流程管理应用,SpringBoot的Spring MVC层对这种业务场景支持非常成熟:RESTful接口怎么写、参数怎么校验、全局异常怎么处理、事务怎么控制,网上有大量成熟方案可以直接参考。而且SpringBoot应用可以打成可执行的JAR包直接运行,对部署环境要求极低,对于学校机房、小型图书馆这种没有专业运维人员的场景是最合适的。
2.2 为什么用 MyBatis,而不是 MyBatis-Plus 或 JPA
选MyBatis的时候我其实犹豫了一下。现在MyBatis-Plus确实很流行,单表CRUD基本不用写SQL,还能省一大堆代码。但我最后还是选了原生MyBatis,理由其实挺实际的。
第一,这个系统的查询场景很多不是简单的单表CRUD,比如借阅明细查询要关联读者表、图书表、操作员表,入馆统计要按天按小时做聚合,这种场景下自定义SQL反而更可控,用一个JOIN或一句GROUP BY就搞定了,如果用ORM的自动方法反而得绕好几层。
第二,原生MyBatis的SQL全掌握在自己手里,SQL能直接复制到数据库客户端里执行验证,排错非常直观。而JPA那种自动生成SQL的方式,一旦出了性能问题或者关联查询出了脏数据,排查的时间成本高很多。
第三,MyBatis XML文件里写SQL有一个实实在在的好处:可以通过条件标签动态拼SQL,实现多条件组合查询。图书馆系统正好有大量这种过滤场景——按书名、作者、分类、出版社、上架时间组合筛选图书,标签拼SQL简洁且直观易维护。
2.3 Vue在前端的角色选择
前端用Vue,主要是看重它的渐进式框架特点。对于一个以表格、表单、列表为核心交互的管理系统,Vue的响应式数据和组件化开发太合适了。
这里有个选型细节值得说一下:Vue 2还是Vue 3。如果是在2023年以前开发,我可能会推荐Vue 2,因为Element UI和大量第三方组件都是围绕Vue 2生态的,踩坑成本低。但这个系统定位是2025年的“最新”方案,所以直接上Vue 3加Element Plus是更合理的。Vue 3的组合式API在处理复杂业务逻辑时比Vue 2的选项式API舒服得多,比如预约流程里“选择时段-填写信息-提交-生成二维码”这一串状态,用组合式API可以拆成独立的响应式变量和函数,逻辑集中度比Vue 2高不少。
2.4 数据库选型与为什么不需要Redis
MySQL作为主存储没有什么争议,这个系统的数据量级在十万条以内,单机MySQL完全能扛住,而且MySQL的事务支持对这个项目至关重要——借书操作要同时扣减库存、生成借阅记录、更新读者在借数量,这三步必须保证原子性,只有事务才能提供这个保证。
至于Redis,我看到很多类似项目的代码里加了Redis做缓存,但实际这个场景有些过度设计。图书馆的图书数据变动频率低,热点数据量不大,MyBatis的二级缓存加上前端本地缓存已经足够。真正的高并发场景——比如某尖峰时段大量读者同时抢预约名额——单纯加Redis缓存也解决不了本质问题,还得靠数据库唯一约束和事务机制来兜底。所以我这个项目一开始就没引入Redis,而是把重点放在数据库层的防并发设计上,效果实录下来反而更稳。
3. 数据库设计与核心表结构
3.1 用户、角色与权限表设计
数据库是整个系统的地基,这一块我建议直接抄下面这套设计,是我实际验证过的。
用户表把读者和管理员统一存放,用角色字段区分身份。角色和权限采用简单的两级设计:一张角色表、一张用户角色关联中间表。不需要做细粒度的菜单权限表,因为系统角色只有三类,功能边界清晰,权限控制完全可以在代码里通过角色判断实现,过度设计反而让系统变得复杂。
核心的表结构如下:
CREATE TABLE `sys_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密密码', `real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `id_card` varchar(18) DEFAULT NULL COMMENT '证件号', `role` varchar(20) NOT NULL DEFAULT 'READER' COMMENT '角色:READER/BOOK_ADMIN/SYS_ADMIN', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态 1启用 0禁用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';密码字段我用了BCrypt加密而不是MD5,这一点很重要。MD5虽然简单,但彩虹表攻击很容易破解常见弱密码。BCrypt在每次加密时自动生成随机盐,同样的密码每次加密结果都不一样,安全性高一个量级。Spring Security自带的BCryptPasswordEncoder直接可以用,不需要额外引依赖。
3.2 图书与借阅相关表
图书表、借阅记录表这两张是图书管理的核心。图书表需要考虑的字段比较多,除了基本的书名、作者、ISBN、出版社,还要有分类、位置、库存总量、当前可借数量、封面图地址、状态。
CREATE TABLE `book_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `book_name` varchar(200) NOT NULL COMMENT '书名', `author` varchar(100) DEFAULT NULL COMMENT '作者', `isbn` varchar(20) DEFAULT NULL COMMENT 'ISBN号', `publisher` varchar(100) DEFAULT NULL COMMENT '出版社', `category` varchar(50) DEFAULT NULL COMMENT '分类', `location` varchar(100) DEFAULT NULL COMMENT '馆藏位置', `total_count` int(11) NOT NULL DEFAULT '0' COMMENT '总量', `available_count` int(11) NOT NULL DEFAULT '0' COMMENT '可借数量', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1上架 0下架', `cover_url` varchar(255) DEFAULT NULL COMMENT '封面图', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category`), KEY `idx_book_name` (`book_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书信息表';借阅记录表是这个系统里查询频率最高的表,也是索引设计最关键的:
CREATE TABLE `borrow_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '借阅人ID', `book_id` bigint(20) NOT NULL COMMENT '图书ID', `borrow_time` datetime DEFAULT NULL COMMENT '借出时间', `due_time` datetime DEFAULT NULL COMMENT '应还时间', `return_time` datetime DEFAULT NULL COMMENT '实际归还时间', `operator_id` bigint(20) DEFAULT NULL COMMENT '操作管理员ID', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0借出 1已还 2逾期', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_book_id` (`book_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅记录表';应还时间(due_time)是在借出时自动计算的,规则是“当前时间+借阅时长(默认30天)”。这个字段不要用SQL的DATE_ADD在查询时计算,直接存一条完整的datetime,后面做逾期判断和列表展示都方便,代价只是多存几个字节。
3.3 疫情专用表:预约、到馆、消毒
这一部分是这个系统的特色,也是跟普通图书管理系统的本质区别。我设计了四张表:入馆预约表、预约时段表、到馆登记表、图书消毒记录表。
入馆预约表是核心,字段设计上有两个关键点。一是时段字段,预约必须绑定到具体时段,否则无法控制同时段人数;二是状态字段,预约状态需要经历“待入场-已完成-已取消-已过期”的变化。另外我用一个last_check_time记录上次核验入场时间,防止同一个预约码被反复核验:
CREATE TABLE `visit_appointment` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '预约人ID', `appointment_date` date NOT NULL COMMENT '预约日期', `time_slot_id` bigint(20) NOT NULL COMMENT '时段ID', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0已预约 1已完成 2已取消 3已过期', `check_time` datetime DEFAULT NULL COMMENT '最近一次核验时间', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_date_slot` (`user_id`, `appointment_date`, `time_slot_id`), KEY `idx_date_slot` (`appointment_date`, `time_slot_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入馆预约表';看到那个唯一约束uk_user_date_slot没有?这个就是防重复预约的兜底方案。用户同一天的同一个时段,最多只能有一条预约记录。应用层代码漏了,数据库层也能拦住。
图书消毒记录表记录了每一本归还图书的消毒时间、操作人、消毒方式、备注。设计上我建了一个中间关联表disinfect_record_book来支持批量消毒操作:
CREATE TABLE `disinfect_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `operator_id` bigint(20) NOT NULL COMMENT '操作管理员ID', `book_id` bigint(20) NOT NULL COMMENT '图书ID', `disinfect_time` datetime NOT NULL COMMENT '消毒时间', `method` varchar(50) DEFAULT NULL COMMENT '消毒方式', `remark` varchar(200) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`), KEY `idx_book_id` (`book_id`), KEY `idx_disinfect_time` (`disinfect_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书消毒记录表';3.4 时段的两个关键:容量与判断
时间时段表字段很简单,关键在容量设计和时段划分方式。我的做法是每天分成几个固定时段,比如上午9:00-12:00、下午13:00-17:00、晚上18:00-21:00。每个时段设置一个最大预约数,这个数就是馆内同时段人数天花板。
CREATE TABLE `time_slot` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `slot_name` varchar(50) NOT NULL COMMENT '时段名称', `start_time` varchar(10) NOT NULL COMMENT '开始时间 HH:mm', `end_time` varchar(10) NOT NULL COMMENT '结束时间 HH:mm', `max_count` int(11) NOT NULL DEFAULT '0' COMMENT '时段最大预约数', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约时段表';判断预约是否已满的核心逻辑基于时段ID和日期做统计查询:查当天该时段预约记录里状态为“已预约”和“已完成”的记录数,大于等于时段max_count就返回已满。注意不能把“已取消”和“已过期”也算进去,否则系统会出现“满员但实际没多少人”的尴尬情况。
4. 后端核心模块落地
4.1 登录与JWT鉴权
后端接口的鉴权我用的是JWT(JSON Web Token),没有引入专门的单点登录框架。每次用户登录成功后,服务端生成一个有效期为24小时的Token返回给前端,前端把它存在浏览器的localStorage里,每次请求在HTTP Header里带上Authorization: Bearer <token>。服务端通过拦截器统一验证Token的有效性,再解析出当前用户ID、角色等基础信息。
拦截器的核心代码实现如下,这套逻辑是我在实际项目中反复调整后定型的版本:
@Component public class JwtInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录和注册接口 String uri = request.getRequestURI(); if (uri.contains("/auth/login") || uri.contains("/auth/register")) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); Long userId = jwtUtil.parseToken(token); if (userId != null) { request.setAttribute("userId", userId); return true; } } response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"登录状态已失效,请重新登录\"}"); return false; } }这里有个细节需要注意:request.setAttribute("userId", userId)把用户ID放到了本次请求的上下文里,后续Controller方法只要声明@RequestAttribute Long userId就能拿到当前用户ID,避免了每个需要用户信息的接口都去解析一遍Token,代码干净很多。这个做法比在每个Controller里调用工具方法解析请求头更干净,也更好维护。
角色权限控制在拦截器里是提炼出来的,通过一个@RequireRole注解加在Controller方法上,在拦截器里拿到用户角色做校验,比在方法里手动判断角色代码重复度低。实际开发中我见过很多项目把角色判断写得到处都是,一旦角色增加或调整权限,改起来很痛苦。
4.2 图书借阅和归还的状态流转
借书和还书是整个系统的事务重点。借书流程是:校验读者存在且状态正常、校验图书可借数量大于0、扣减图书可借数量、创建借阅记录、设置应还时间。还书流程是:找到对应的未归还借阅记录、更新记录状态和实际归还时间、增加图书可借数量、生成消毒任务。
借书操作必须加@Transactional注解保证事务性。我见过有人把扣库存和生成借阅记录写成了两个接口,前端调两次完成借书,这种情况下如果第二次请求失败,就会出现“书没了但记录没生成”的数据不一致问题。
借书接口的核心逻辑简化如下:
@Transactional(rollbackFor = Exception.class) public BorrowResult borrowBook(Long userId, Long bookId, Integer borrowDays) { // 1. 查询图书并加锁,防止并发借阅导致超借 BookInfo book = bookInfoMapper.selectByIdForUpdate(bookId); if (book.getAvailableCount() <= 0) { throw new BusinessException("该图书暂无可借数量"); } // 2. 扣减可借数量 bookInfoMapper.decreaseAvailableCount(bookId, 1); // 3. 创建借阅记录 return new BorrowResult(); }这里selectByIdForUpdate是一个InnoDB的SELECT FOR UPDATE操作,查询图书记录时加行级排它锁,并发借同一本书时第二个请求会阻塞直到第一个请求提交。不加上这个,就会出现两个人同时借同一本库存还剩一本的书,两个请求都读到available_count=1,最终都借走的情况。这是库存类系统最典型的并发问题,图书馆借还场景一样存在。
应还时间的默认值是借出后30天,管理员在界面上可以配置这个参数,我把它放到了系统参数表里,这样后续调整规则不用改代码重新发版。
4.3 预约入馆与时段限流实现
预约接口是疫情场景下并发压力最大的接口。这里做了一个重要决策:预约时先查询时段已预约人数,再判断是否已满,整个判断和插入动作放在事务里完成,并依靠数据库唯一约束兜底。
预约接口核心逻辑分四步:
@Transactional(rollbackFor = Exception.class) public AppointmentResult makeAppointment(Long userId, Long slotId, LocalDate date) { // 1. 校验日期和时段是否合规 // 2. 查询当前时段已预约人数(不含取消和过期) int counted = visitAppointmentMapper.countValidByDateAndSlot(date, slotId); TimeSlot slot = timeSlotMapper.selectById(slotId); if (counted >= slot.getMaxCount()) { throw new BusinessException("该时段预约人数已满"); } // 3. 尝试插入预约记录,唯一约束保证同人同时段唯一 try { insertAppointment(userId, slotId, date); } catch (DuplicateKeyException e) { throw new BusinessException("您已预约过该时段,请勿重复预约"); } // 4. 生成预约码(后续扫码核验使用) return new AppointmentResult(); }并发环境下,两个请求同时执行到第2步时可能都读到未满,然后同时走插入。这时唯一约束能把后者拦下来,加上事务后即使出现竞态,最终数据库中也只会有一条有效记录。这就是我在选型部分说的“不依赖Redis做限流,而是靠数据库约束兜底”的具体落法。
预约成功后,后端生成一个预约码,用时间戳加用户ID加随机数拼出来,然后用MD5转成简短字符串。这个预约码会以二维码形式展示在读者端的预约记录里,管理员扫描二维码后在系统内查询对应预约记录并核验入场生成“已完成”状态。
4.4 图书消毒流程
还书之后图书不能立即上架,必须先消毒。我在图书表里加了一个字段disinfected_status用来标识图书当前的消毒状态:0-待消毒、1-消毒中、2-已消毒可上架。
还书接口的收尾工作是:更新借阅记录为已还、增加图书可借数量、把这本书的消毒状态改成“待消毒”。管理员在“待消毒图书”列表里勾选需要消毒的书,点击消毒完成,系统批量更新状态并生成消毒记录。这套流程虽然逻辑简单,但很好地还原了疫情下图书馆真实的管理要求,也保证了每一本消毒后的书都有记录可查。
4.5 定时任务与到期处理
系统有两个典型的定时任务需要处理:一个是把预约日期是当天且已经过期的“已预约”记录改为“已过期”,另一个是把借阅记录中超过应还时间且状态为借出的记录标记为逾期。
这两个任务都用Spring自带的@Scheduled注解实现,在配置类上启用@EnableScheduling后,每天凌晨1点执行一次。实际上第二个任务的逾期标记并不影响读者界面展示的逾期状态——列表查询时直接用due_time和当前时间动态比较也行——但有一个预生成的逾期状态可以让管理员端的“逾期统计”环节直接按status过滤,不用每次实时计算,数据量大了查询速度更快。
@Component public class SystemScheduledTasks { @Scheduled(cron = "0 0 1 * * ?") public void markExpiredAppointments() { visitAppointmentMapper.markExpiredRecords(LocalDate.now()); } @Scheduled(cron = "0 30 1 * * ?") public void markOverdueBorrowRecords() { borrowRecordMapper.markOverdueRecords(LocalDateTime.now()); } }这里要提醒一个坑:@Scheduled是单机定时任务,服务器上如果部署了多个实例,同一个任务会被执行多次,造成重复更新。虽然这个系统的小规模场景通常只有单实例部署,但如果后续扩展,建议引入分布式任务锁或把定时任务放到独立的调度服务里。
5. 前端Vue关键细节
5.1 路由守卫与Token处理
Vue前端部分我用了Vue 3 + Vue Router 4 + Element Plus的组合。前端路由分成两部分:读者端和管理员端,各自独立的路由前缀。
前端的关键点是路由守卫。系统不允许未登录用户访问任何业务页面,登录页是唯一不需要登录态的页面。路由守卫的实现思路是:在router.beforeEach中读取本地Token,没有Token且访问非登录页,直接跳转到登录页;有Token则放行。但管理员端的路由还要额外判断角色,比如BOOK_ADMIN角色访问系统管理页面就要被拦下来。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('library_token') if (to.path === '/login' || to.path === '/register') { next() return } if (!token) { next('/login') return } const userInfo = JSON.parse(localStorage.getItem('library_user') || '{}') if (to.meta.roles && to.meta.roles.indexOf(userInfo.role) === -1) { next('/403') return } next() })前端路由守卫只是用户体验层面的保护,真正的安全防线在后端拦截器和权限校验上。前端隐藏了按钮不代表安全问题不存在,恶意用户构造请求照样能访问到接口,所以前后端都要做权限控制,只是层次不同。
5.2 Axios封装与请求拦截
Axios封装我做了统一处理:请求拦截器统一为每个请求附带Authorization请求头,响应拦截器统一处理状态码和业务码。特别是遇到401状态码时自动清理本地登录信息并跳转到登录页,这个体验很重要,否则后台接口报错了用户还停留在页面上不知道发生了什么。
axios.interceptors.request.use(config => { const token = localStorage.getItem('library_token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) axios.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } ElMessage.error(res.msg || '请求失败') return Promise.reject(res) }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('library_token') localStorage.removeItem('library_user') router.push('/login') } ElMessage.error(error.response?.data?.msg || '网络异常') return Promise.reject(error) } )后端返回的数据格式统一是{code, msg, data},code为200表示成功,非200表示业务异常。用这个统一格式,前端放行判断就非常简单,不用每个接口都单独做一层异常判断。实际操作中看到有很多项目是直接把后端对象当响应返回,前端拿到的结构五花八门,遇到嵌套对象还要一层层去点,统一封装省下不少事。
5.3 预约页面的交互设计
预约页面是读者端最重要的交互页面。用户选择日期、选择时段、提交预约、查看我的预约。交互上有一个关键点:选择了日期之后,时段列表需要显示每个时段当前“已满/可约”的状态,这样用户不用提交后才知道约不上。
这块我用了一个简单的做法:进入页面时调用后端接口一次性返回未来7天的所有时段和剩余名额数据,前端直接渲染表格。这个方案对后端来说就是一次聚合查询,数据量很小,前端交互也流畅,不需要做复杂的异步级联加载。
剩余名额的计算逻辑后端已经处理好了,前端拿到了就直接展示。要注意的是用户从选择时间到提交预约之间可能有几秒的间隔,期间名额恰好被抢光的极端情况,此时前端展示还是“可约”但后端提交会返回“已满”,所以前端要捕获这个异常并提示用户重新选择时段。
5.4 二维码生成与核验
预约成功后生成的预约码展示方式,前端用了qrcode库在Canvas上绘制二维码。生成之后用户的预约详情页里就有一张二维码图片,管理员端核验页面用扫描获取的预约码字符串调用后端接口核验。
这里提醒一点:二维码里放的是预约码字符串,不是预约ID,也不是一个完整的URL。完整URL会暴露你的后端接口地址,而且预约码本身携带的信息足够识别记录,没必要多暴露信息面。管理员端我做了两种核验方式:扫码枪输入和手动输入预约码,都调用同一个核验接口,体验一致。
6. 实测踩坑与问题排查实录
6.1 数据库连接与中文乱码
服务器数据库用的是MySQL,部署后出现中文乱码,排查后发现是建库时没有明确指定字符集。MySQL 8.0默认字符集是utf8mb4,但在某些版本配置下默认可能是latin1,导致中文写入后变成问号。
解决办法是在JDBC连接串里显式指定字符集:
spring.datasource.url=jdbc:mysql://localhost:3306/library?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowMultiQueries=true这里同时把serverTimezone设置为Asia/Shanghai,解决了后文的时间问题。另外建库时也要指定:
CREATE DATABASE library DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;字符集问题看起来简单,但它会在你部署上线后集中爆发:所有中文都变成了问号,数据还不好修复,所以一开始就要在多个环节同时锁定字符集,不要只在数据传输层设置。
6.2 时间比较相差8小时
项目里用LocalDateTime存取时间,数据库存的是datetime类型。第一次联调时发现前端展示的借阅时间比实际时间晚(或早)8小时,本质原因有两个:一是JDBC连接串里的serverTimezone没有设置为Asia/Shanghai,MySQL驱动默认用服务器的时区生成时间;二是后端接收前端传过来的时间字符串时,没有明确指定时区,Jackson反序列化默认使用UTC。
解决方式:JDBC连接串如前文所述设置serverTimezone,同时在后端统一配置Jackson时间处理,前端传时间用时间戳或带时区的ISO字符串,后端统一解析后再转换。我最终选择在前端传时间戳,后端用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")统一格式化输出,这样不管用户在国内哪个地区访问,看到的时间都是东八区时间。
6.3 并发环境下预约“超卖”
我第一次做并发测试时用Jmeter模拟100个用户同时预约同一个时段的最后5个名额,结果数据库里出现了8条有效预约记录。当时立即意识到这是典型的并发竞态问题,解决方案是给预约表加上唯一约束(前文的uk_user_date_slot)并且把后端判断和插入放进同一个事务中。
唯一约束解决的是重复预约,但要解决名额超卖,还需要保证“查询计数”和“插入记录”这两个操作在同一时间点上对其他事务不可见。所以我的实现里用selectByIdForUpdate锁住了时段表里的时段记录,这样同一时段的并发预约会串行执行,后面的请求在前一个事务提交后才能拿到最新计数。
6.4 跨域问题与前端联调
开发环境里前端跑在5173端口,后端跑在8080端口,浏览器同源策略直接拦截了跨域请求。我采用的解决方式是后端配置一个全局CORS:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("http://localhost:*", "http://127.0.0.1:*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)和allowedOriginPatterns("http://localhost:*")的配合,如果只配置allowedOrigins("*")配合allowCredentials(true),浏览器会拒绝带凭证的跨域请求,这也是很多CORS配置看似正确但请求还是失败的原因。
6.5 Element Plus表单校验的一个坑
前端使用Element Plus的Form表单校验时,有一个容易踩的坑:自定义校验器里用到异步请求校验数据(比如校验证件号是否已被注册),这时如果校验器没有正确调用callback,表单会一直处于校验中状态,提交按钮永远只有loading。
正确的自定义校验器写法必须是调用callback()或callback(new Error('提示信息'))来明确结束校验。在异步逻辑里,尤其要注意不能提前走完所有代码路径而忘记回调,这会导致表单提交永远等待,且控制台没有任何报错,排查起来非常耗时间。
7. 部署上线与后续扩展
7.1 前端打包与Nginx配置
前端部署我用Nginx做静态服务,将Vue项目打包产物直接放到指定目录,再配置反向代理转发/api前缀的请求到后端的8080端口:
server { listen 80; server_name your-domain.com; root /data/www/library-front; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html;这一行是前端路由History模式的基础,没有它,页面刷新时Nginx会在文件系统里找对应的路由路径,结果找不到直接返回404。
7.2 后端JAR包部署与日志
后端打包用Maven的spring-boot-maven-plugin,执行mvn clean package -DskipTests打出来的可执行JAR大概60MB左右,包含全部依赖。服务器上用java -jar library-system.jar启动即可,配合nohup命令让进程后台运行。
日志处理是上线后最容易忽视的环节。我在application.yml里配置了日志文件滚动,按天生成,保留最近30天:
logging: file: name: /data/logs/library-system.log logback: rollingpolicy: max-history: 30 max-file-size: 10MB有日志兜底,出了问题才能快速定位,这是经历过几次线上问题后的深刻体会。
7.3 系统还能扩展成什么
这套“预约 + 核验 + 资源状态管理 + 记录追溯”的组合模型,本质上不只是图书管制的管理系统,它适合很多资源型预约场景:
- 自习室座位预约,把预约时段从半天改成按小时,座位表和时段表关联即可
- 实验室设备使用预约,增加设备状态记录,预约后生成使用任务
- 会议室的资源管理,把图书表换成会议室表,把借阅记录换成使用记录
- 校医院挂号或场馆活动预约,逻辑完全可复用
系统结构上,因为预约、核验、资源、记录这些模块已经独立成清晰的服务接口,后续扩展时不需要改动既有代码,只需新增资源类型对应的表和接口,业务集成成本很低。这也是我在设计初期坚持“把疫情相关业务和常规图书管理拆分成两条业务线”带来的额外收益。
个人体会最深的一点是:项目最容易出的问题不在新功能上,而在那些“看起来没技术含量”的细节上——数据一致性、时间时区、并发竞态、部署路径配置。开发时多花一点精力把这些基础设施问题一次解决,部署和演示的时候才能真正省心。如果你在实现过程中卡在某个环节,文中提到的表结构和核心代码基本可以直接参考,环境和版本差异导致的坑,按照自己在第6章里从表结构设计一路排查下来的经验,通常也能快速找到症结。