先交代个项目背景这个源码项目是我在实际带团队时为一个高校图书馆做的“疫情常态化下的预约借阅管理系统”。技术栈就是标题里那套——SpringBoot Vue3 MyBatis MySQL前后端完全分离部署时前端打包丢 Nginx后端直接 Jar 包跑在服务器上。如果你正打算做毕业设计、课设或者刚入行想学“前后端分离项目到底怎么落地”那这套系统的拆解过程应该能给你省不少力气。因为它的业务不算复杂但涉及的工程点很全权限校验、预约冲突处理、库存扣减、跨域联调、生产部署每一个都是实际开发里躲不掉的坎。我先把整体设计思路、每个模块怎么拆、数据库表怎么建、前后端怎么对接、上线部署踩了哪些坑完整过一遍。整篇内容我尽量站在“做项目”的角度讲不绕概念。1. 项目整体设计与技术选型解析1.1 为什么是这套组合SpringBoot Vue3 MyBatis MySQL先说技术选型。很多人上来就问“用这个行不行、用那个行不行”其实选型的关键在于匹配场景而不是盲目追新。这套组合在图书馆管理系统这个场景下几乎是最稳的搭配。SpringBoot负责后端接口服务。它自带内嵌Tomcat不用额外装容器一个java -jar就能跑起来。对于课程设计和中小型项目来说这就够用了。SpringBoot还帮你把Spring MVC、事务管理、参数校验、JSON序列化这些基础设施都整合好了不需要你用一堆XML去拼一个能跑的项目。Vue3负责前端页面。选Vue3而不是Vue2一方面是因为Composition API在复用逻辑、组织代码上确实清爽很多另一方面现在Element Plus这类组件库全面支持Vue3后台管理系统的表格、表单、弹窗、分页组件都很齐全开发效率比手写DOM高太多。MyBatis负责数据库操作。图书馆管理系统的SQL逻辑并不复杂但也存在“条件查询、多表联查”这类灵活需求MyBatis的XML里写动态SQL非常灵活系统运行起来以后SQL好不好调优、能不能看懂比那种“啥都帮你生成”的ORM框架要实在得多。如果你觉得MyBatis手写映射太啰嗦也可以配合MyBatis-Plus使用但我建议初学阶段把原生MyBatis跑通一遍这样你对SQL执行过程的理解会扎实很多。MySQL负责存储数据。图书馆系统的数据量级在绝大多数高校场景下也就是几万到几十万条记录MySQL完全扛得住。配合InnoDB引擎、合理索引查询速度轻松做到毫秒级。1.2 疫情场景带来的业务需求变化标题里特意强调“疫情下”这不只是为了选题新颖它确实改变了图书馆的业务流程。传统图书馆管理系统的核心是“借书-还书-管库存”但疫情期间多了一个刚需到馆预约和限流。也就是说系统不只是管书还要管“人什么时候能来、来了坐在哪儿、同一时间段馆内人数不能超限”。落实到功能上就比普通图书管理系统多出两个模块预约入馆和自习座位预约。这直接决定了系统的功能边界用户端读者注册登录、检索图书、预约图书、预约入馆、查看个人借阅记录。管理端馆员/管理员图书管理、分类管理、用户管理、借阅审核、预约审核、入馆记录统计、公告发布。说到底这是我见过最典型的“单体应用”项目——业务是真实的但不是互联网那种高并发场景更适合把精力放在代码结构、业务逻辑完整性和工程技术规范上。1.3 前后端分离架构图的“脑内模型”很多人一说“前后端分离”就以为只是把代码分成两个文件夹其实核心在于交互方式。前端只负责页面展示和用户操作后端只负责业务逻辑和数据处理双方通过 HTTP 接口 JSON 数据通信。我习惯用这种脑内模型来理解前后端交互前端浏览器访问页面通过 Axios 向后端接口发送请求。后端 Controller 接收请求Service 处理业务逻辑Mapper 操作数据库。后端返回统一的 JSON 结构code message data。前端拿到 JSON 后渲染到页面上。这里面不需要模板引擎不需要 JSP前端就是纯静态页面后端就是纯接口服务。两者可以分别在两个端口上开发调试前端开发时通过代理转发请求到后端上线时前端构建成静态文件交给 Nginx后端打成 Jar 包独立运行。这个架构模式一旦想通你就掌握了大多数后台管理系统的通用开发方式。2. 数据库设计与核心表结构2.1 我需要哪些表从业务反推表结构数据库设计是一切功能的基础我习惯先列业务实体再定表结构。这套系统的实体和对应表大致如下实体表名说明用户读者/管理员t_user区分角色读者、图书管理员、系统管理员图书分类t_category图书的分类层级如文学、计算机、历史图书信息t_book种次信息书名、作者、ISBN、馆藏总数、可借数量图书实体t_book_item每一本具体的书如“某本书的第3册”借阅记录t_borrow_record借书、还书、续借的记录流水预约记录t_reservation读者预约某本书或预约入馆的记录入馆预约时段t_visit_time一天内开放的入馆时间段及名额上限公告信息t_notice管理员发布的公告核心表的DDL我挑几张给出参考。这张是图书表注意我特意加了total_stock和available_stock两个字段前者是馆藏总数后者是当前可借数量。很多初学者只存一个总数然后靠“借出记录”实时计算剩余数量这样做不仅每次查询都要多表运算而且很容易在并发借书时出错CREATE TABLE t_book ( id bigint(20) NOT NULL AUTO_INCREMENT, isbn varchar(32) DEFAULT NULL COMMENT ISBN编号, book_name varchar(128) NOT NULL COMMENT 书名, author varchar(64) DEFAULT NULL COMMENT 作者, publisher varchar(64) DEFAULT NULL COMMENT 出版社, category_id bigint(20) DEFAULT NULL COMMENT 分类ID, total_stock int(11) NOT NULL DEFAULT 0 COMMENT 馆藏总数, available_stock int(11) NOT NULL DEFAULT 0 COMMENT 可借数量, cover_image varchar(255) DEFAULT NULL COMMENT 封面图地址, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1上架 0下架, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_book_name (book_name), KEY idx_isbn (isbn) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;预约表是疫情场景下新增的核心表它记录了“哪位读者预约了什么时间段或者预约了哪本书”CREATE TABLE t_reservation ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 读者ID, reservation_type tinyint(4) NOT NULL COMMENT 类型1图书预约 2入馆预约, book_id bigint(20) DEFAULT NULL COMMENT 预约的图书ID, visit_date varchar(20) DEFAULT NULL COMMENT 预约入馆日期, visit_time_id bigint(20) DEFAULT NULL COMMENT 入馆时间段ID, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0待处理 1已确认 2已取消 3已完成 4已过期, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_visit_date (visit_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这张表在设计时有一个取巧之处用reservation_type把“图书预约”和“入馆预约”统一收到一张表里。这两个业务本来可以拆成两张表但它们的核心流程高度相似——都是“预约、审核、核销、过期处理”合并成一站在管理后台处理起来非常顺手代码里也能复用同一个状态机逻辑。借阅记录表相对常规CREATE TABLE t_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 实际归还时间, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0借出 1已还 2逾期 3续借, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_book_id (book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.2 数据库设计中的两个关键决策冗余与软删除先讲“冗余”。t_book表里的available_stock就是一个明显的冗余字段它可以通过“借出记录 馆藏总数”算出来。但我在设计时选择在每次借书、还书操作时同步维护这个字段而不是查询时临时计算。原因很简单在图书列表页和详情页这个字段被查询得太频繁了每次都用count(*)统计索引压力和查询耗时都会有明显提升。对于管理系统这种量级“维护冗余字段”比“实时计算”更值得。再讲“软删除”。图书、用户这些主数据我都没有做物理删除DELETE FROM ...而是用status字段标记上下线、禁用状态。为什么因为借阅记录、预约记录都外链到这些表一旦物理删除历史记录就变成“查无此书”“查无此人”的脏数据。系统跑一段时间后你一定会遇到“这本书已经下架了但历史借阅记录还要能显示书名”的需求软删除是唯一的体面解法。查询时统一带上WHERE status 1就行。2.3 时间字段处理与索引设计MySQL 里时间字段我统一用datetime在 Java 中使用LocalDateTime接收。这里有个坑必须提前说datetime和timestamp的时区处理逻辑不同如果你用timestamp应用服务器和数据库服务器时区不一致时查出来的时间会“差8小时”。所以我建议直接用datetime写入时由 Java 端统一生成时间代码里指定好Asia/Shanghai时区能少踩很多莫名其妙的坑。索引设计方面原则很简单高频查询条件建索引低区分度字段不建单列索引。比如t_book表的book_name、isbnt_borrow_record表的user_id、book_id组合条件都是索引重点。像status这类值只有 0、1、2 几个取值的字段建单列索引意义不大通常和别的字段组成联合索引才会生效。3. 后端核心实现与关键细节3.1 项目分层与模块组织我建后端工程时的包结构很简单直接com.example.library ├── controller # 接收请求、参数校验、返回结果 ├── service # 业务逻辑层接口 impl ├── mapper # MyBatis数据访问层接口 ├── entity # 数据库实体类 ├── dto # 请求参数对象 ├── vo # 响应视图对象 ├── config # 配置类跨域、拦截器、全局异常 ├── common # 通用类统一返回、常量、枚举 └── utils # 工具类JWT、日期处理等这套分包方式可能不算“最先进”但胜在直观每个层承担什么职责一眼就能看懂。特别提醒一个点Controller 尽量不要塞业务逻辑Controller 只做参数接收、简单校验、调用 Service真正的判断逻辑都放在 Service 里。这样做的最大好处是当你在一个方法里牵扯到“扣库存 生成借阅记录 更新预约状态”这种多步骤操作时事务注解Transactional能稳稳地包住整个业务方法保证原子性。如果业务逻辑散落在 Controller 里事务边界就很难控制。3.2 统一返回结果与全局异常处理管理系统的后端接口必须有一套“统一返回格式”否则前端每次都要猜这个接口到底返回了什么结构。我的统一返回类核心代码大致这样Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }配合一个RestControllerAdvice全局异常处理类将业务异常、参数校验异常、数据库异常统一封装成上述结果返回。这样做的好处前端 Axios 响应拦截器只需判断code 200走成功逻辑其他统一走报错提示不用每个接口单独处理错误分支。3.3 MyBatis 动态SQL与分页查询实践MyBatis 的精髓在 XML 里的动态 SQL。图书搜索这个功能请求参数可能是书名 分类 是否仅看可借甚至可能什么都不传。用动态SQL逐个拼接条件是最合适的写法select idselectBookPage resultTypecom.example.library.vo.BookVO SELECT b.id, b.isbn, b.book_name, b.author, b.publisher, c.category_name, b.total_stock, b.available_stock, b.cover_image FROM t_book b LEFT JOIN t_category c ON b.category_id c.id where if testbookName ! null and bookName ! AND b.book_name LIKE CONCAT(%, #{bookName}, %) /if if testcategoryId ! null AND b.category_id #{categoryId} /if if testonlyAvailable ! null and onlyAvailable true AND b.available_stock gt; 0 /if AND b.status 1 /where ORDER BY b.update_time DESC /select分页我用的是 PageHelper用法极其简单在 Service 层查询前写一行PageHelper.startPage(pageNum, pageSize)后面紧跟的查询就会自动拼接LIMIT并返回分页数据。但 PageHelper 有一个坑它生效的“最近的一条查询”所以startPage和select之间不要夹杂任何其他查询语句否则分页会被无情地加在错误的 SQL 上。还有一个 MyBatis 老生常谈的坑当test条件里判断的参数是Integer类型时不要用status ! 这种写法否则当 status 为 0数字零时MyBatis 会把空字符串和 0 比较出现神秘问题。判断数字只写! null就够了。3.4 借书、还书核心业务的“事务边界”设计借书流程是这套系统里业务逻辑最繁琐的部分。完整流程如下校验用户状态是否正常是否有逾期未还的图书。查询图书信息判断available_stock是否大于 0。生成借阅记录状态为“借出”。图书表available_stock减 1。如果这本书之前有预约记录把状态改成“已完成”。这里有一个典型的多步跨表操作必须加上Transactional保证原子性。要特别注意的是“超借”问题两个用户同时借同一本书的最后库存怎么办我的处理方式是加一层乐观锁。更新库存的 SQL 写成了UPDATE t_book SET available_stock available_stock - 1 WHERE id #{bookId} AND available_stock 0然后判断update的返回影响行数如果为 0说明库存已经没有了或者发生了并发竞争直接抛出业务异常“库存不足”。这种做法比先SELECT再UPDATE要安全得多因为你在UPDATE时就把“库存 0”作为条件数据库层面的锁定天然帮你挡住了超卖。还书流程则是对称的更新借阅记录状态为“已还”填入return_time然后把图书的available_stock加 1。还有一个容易被忽略的细节逾期判断。系统里我先预留了due_time字段正常情况下前端列表页展示“应还时间”。在还书时如果return_time晚于due_time我会在事务里额外更新借阅记录的状态为“已还逾期”并把当前用户标记为“有逾期记录”下次借书时就不允许借了。3.5 登录鉴权与角色权限控制我用 JWT 做登录态管理核心依赖是jjwt。登录成功后后端生成一个带有userId、role信息的 token 返回给前端前端存储到 localStorage。后续请求在请求头带上Authorization: Bearer token后端拦截器统一校验和解析。我自定义了一个拦截器注册时指定拦截路径放行登录接口、注册接口其他接口全部校验 token。核心逻辑大致是public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { // 未登录返回401 response.setStatus(401); return false; } String token authHeader.substring(7); try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { response.setStatus(401); return false; } }角色权限我没有引入 Spring Security因为这套系统的权限模型很简单只有两种角色读者、管理员。管理员接口再做一层简单校验——在 Controller 方法里判断role属性即可。如果你需要更复杂的权限模型比如多级管理员、菜单权限、按钮权限再引入 Spring Security JWT 的方案也不迟但对图书馆管理系统来说轻量解决方案完全够用。接口设计上我建议所有后台管理接口统一以/admin开头读者端接口以/api开头这样拦截器配置和管理员校验都是一目了然的。3.6 定时任务处理“过期预约”预约入馆有一个隐含需求预约了今天上午的时间段但读者没来系统应该在时间段结束后自动把这条预约标记为“已过期”并把该时段的名额释放出来。这个功能用 Spring Boot 自带的Scheduled注解即可实现。我在启动类上加了EnableScheduling然后写了一个定时任务每 30 分钟执行一次把visit_date 今天且状态为“已确认”的预约批量更新为“已过期”。其中释放名额的逻辑是关联时间段的remaining_quota字段做加法。定时任务这一块有个实际开发中的大坑如果线上部署了多台服务器Scheduled任务会在所有实例上同时执行。这个项目的量级单机部署就够了所以我没上分布式锁。但你在写代码时要注意定时任务的幂等性比如更新 SQL 本来就可以通过状态条件控制重复执行无副作用。4. 前端 Vue3 Element Plus 实操要点4.1 开发环境搭建与项目初始化前端这一侧我强烈建议用 Vite 来构建项目而不是 Webpack。Vite 在启动速度和热更新上完全是碾压级的体验配合 Vue3 让你的开发过程流畅很多。我创建项目的命令npm create vitelatest library-frontend -- --template vue cd library-frontend npm install npm install vue-router4 pinia axios element-plus element-plus/icons-vueVite 创建完项目后开发时的代理配置在vite.config.js里。我配了server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样做的好处是前端请求/api/login时会自动转发到后端http://localhost:8080/api/login同时解决了本地开发的跨域问题。上线部署后Nginx 再配置同样的转发规则前端代码不用改一行。4.2 Vue3 核心用法组合式 API 与组件拆分Vue3 和 Vue2 的最大区别就是 Composition API。我在写读者借阅记录页面时把“查询表单、表格数据、分页信息”放在同一个setup逻辑块里用ref、reactive管理状态用onMounted调接口。组件内部不需要在data、methods、watch之间来回跳转一个模块的代码集中在一起阅读体验好很多。以图书列表页为例核心思路template el-card el-form :modelsearchForm inline el-form-item label书名 el-input v-modelsearchForm.bookName placeholder请输入书名 clearable / /el-form-item el-form-item label分类 el-select v-modelsearchForm.categoryId placeholder请选择分类 clearable el-option v-foritem in categoryList :keyitem.id :labelitem.categoryName :valueitem.id / /el-select /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button el-button clickresetSearch重置/el-button /el-form-item /el-form el-table :databookList v-loadingloading border stripe el-table-column propbookName label书名 min-width180 show-overflow-tooltip / el-table-column propauthor label作者 width120 / el-table-column propcategoryName label分类 width100 / el-table-column proptotalStock label馆藏 width80 / el-table-column propavailableStock label可借 width80 / el-table-column label操作 width180 fixedright template #default{ row } el-button typeprimary link clickhandleBorrow(row)借阅/el-button el-button typewarning link clickhandleReserve(row)预约/el-button /template /el-table-column /el-table el-pagination v-model:current-pagepageNum v-model:page-sizepageSize :totaltotal :page-sizes[10, 20, 50] layouttotal, sizes, prev, pager, next size-changeloadData current-changeloadData / /el-card /template这段代码复制到项目里稍作调整就能跑起来。Element Plus 表格组件的v-loading指令、show-overflow-tooltip属性都很实用长文本自动省略鼠标悬浮显示完整内容体验比手写样式好得多。4.3 Axios 封装与请求拦截前端所有请求我都封装在一个request.js模块里统一设置baseURL、请求头、响应拦截。响应拦截器做了两件事拿到的数据里code不是 200 时自动弹出错误提示状态码是 401 时自动清除登录信息并跳转到登录页。import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message || Error)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) localStorage.removeItem(userInfo) router.push(/login) } ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request很多初学者会在每个组件里发请求然后到处重复写token处理逻辑代码乱成一锅粥。统一封装之后后续新增任何接口只需在 API 模块里写export const getBookList (params) request.get(/book/list, { params }) export const borrowBook (data) request.post(/borrow/add, data)组件里调用的代码变得非常干净。4.4 路由守卫与动态侧边栏管理后台系统普遍有一个需求未登录用户访问某个页面时直接踢回登录页。Vue Router 的beforeEach全局前置守卫正好做这件事router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else if (!token) { next(/login) } else { next() } })侧边栏菜单我采用静态路由匹配的方式管理员和读者各有一套菜单通过meta.roles字段控制页面加载时根据当前用户角色过滤出可见菜单。这个方法比动态生成路由要简单可靠得多也更适合课程设计和中小型系统。4.5 表单校验的使用心得前端表单校验是管理系统体验感的重要一环。 Element Plus 的el-form提供了rules机制我给你一个成熟的配置模板el-form refformRef :modelform :rulesrules label-width80px el-form-item label书名 propbookName el-input v-modelform.bookName / /el-form-item /el-formconst rules { bookName: [ { required: true, message: 请输入书名, trigger: blur }, { min: 2, max: 50, message: 书名长度在2到50个字符之间, trigger: blur } ] }提交前通过formRef.value.validate()做整体校验校验不通过会自动聚焦到第一个错误项。注意trigger的设置输入框用blur或change下拉框用change。5. 前后端联调与部署上线实录5.1 开发环境跨域联调与问题排查前后端分离开发时最常见的“初次联调现场”就是浏览器控制台一片红色报错Access to XMLHttpRequest at http://localhost:8080/api/login from origin http://localhost:5173 has been blocked by CORS policy。我的处理方式就在前面说的vite.config.js里配置代理前端所有请求走相对路径/api由 Vite 转发到后端。这样浏览器看到的请求是同源的跨域问题直接消失。如果你后端单独调试接口也可以在后端加一个全局跨域配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }但我个人的建议是开发环境用代理不要依赖后端开启跨域。因为上线后前后端是同一个域Nginx 转发你根本用不上 CORS。后端一旦开启allowCredentials(true)还允许所有源反而会带来安全隐患。5.2 生产环境部署的完整步骤生产环境我用一台 2核4G 的云服务器CentOS 7 系统。部署目标Java 后端 Jar 包占用 8080 端口Nginx 监听 80 端口前端打包后的静态文件放在/usr/share/nginx/html目录Nginx 将/api开头的请求转发到后端 8080 端口。后端打包命令mvn clean package -DskipTests打包后目标目录下会有library-server-0.0.1-SNAPSHOT.jar。我习惯在服务器上用systemd管理服务创建/etc/systemd/system/library.service[Unit] DescriptionLibrary Server Afternetwork.target [Service] Userroot WorkingDirectory/opt/library ExecStart/usr/local/java/bin/java -jar /opt/library/library-server.jar --spring.profiles.activeprod Restarton-failure RestartSec5 [Install] WantedBymulti-user.target之后执行systemctl daemon-reload systemctl enable library systemctl start library服务就托管给系统管理了。比直接nohup java -jar要正规得多服务器重启后服务也会自动拉起。前端构建npm run build构建完成后把dist目录下所有文件传到 Nginx 的 html 目录。Nginx 配置的核心部分server { listen 80; server_name your_domain; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # 前端路由刷新时回退到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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里最关键的是try_files $uri $uri/ /index.html。Vue Router 默认用 History 模式如果不用这个配置用户访问/book/list再按 F5 刷新Nginx 会去找一个不存在的/book/list文件然后报 404。5.3 常见错误排查速查表我整理了一份我在联调和上线过程中踩过的坑对照查找能省很多时间现象根本原因解决方案后端查询时间比数据库时间差8小时JDBC 连接串未指定时区在jdbc:mysql://...后加serverTimezoneAsia/Shanghai前端请求返回 401但登录接口正常token 过期或拦截器未放行该接口检查拦截器排除路径配置插入的记录中文乱码数据库连接未指定字符集JDBC 连接串加characterEncodingutf8分页查询总数不对PageHelperstartPage后还有别的查询确保startPage紧跟目标查询前端刷新页面后 404Nginx 未配置try_files补上try_files $uri $uri/ /index.html;本地联调正常服务器请求超时服务器防火墙未放行端口开放 80 和 8080 端口或使用 Nginx 统一代理删除图书后历史记录查不到书名物理删除导致外键数据丢失改用逻辑删除status0表示下架构建前端时提示vite不是内部命令依赖未安装完整先执行npm install或者删除node_modules后重装5.4 并发借书与“最后一本”的处理细节再回到并发那个话题。我前面用UPDATE t_book SET available_stock available_stock - 1 WHERE id ? AND available_stock 0的方式做了乐观锁。实际联调时用 JMeter 模拟 20 个并发请求同时借同一本书最终确实只有available_stock对应的数量能成功其余全部被拦截。这个验证过程不是多余的因为在真实场景里学生同时抢一本书的预约是很常见的事不做并发控制就会超借。需要注意的是这个方案虽然挡住了超借但如果你的系统后续量级变大同一本书在同一时刻的借阅请求量达到几百甚至上千你还是需要考虑引入 Redis 分布式锁把借书操作串行化同时给数据库连接池和事务隔离级别做更细致的调优。以图书馆管理系统的量级来说到这里已经足够。6. 这套系统后续可以怎么扩展这个项目做到能跑、能部署只是第一步。以图书馆管理系统为基底后续有很多值得折腾的方向我简单说说如果觉得检索速度不够快就把热门图书和公告缓存到 Redis 里定时刷新数据库查询压力会显著下降。如果要把“预约到书短信通知”做出来可以接入阿里云短信或者微信模板消息预约图书到馆后自动通知读者。如果要把统计做成大屏展示前端接入 ECharts把每日到馆人数、热门图书排行榜、借阅量趋势做成可视化图表放在图书馆大厅的展示屏上很出效果。如果要支持复杂权限比如不同管理员只能维护不同分类的图书那就引入 Spring Security把角色权限细化到按钮级别。从工程角度来说这套系统的通用性很强图书管理换成商品管理、设备管理、资产管理核心架构基本不用动。很多管理系统的骨架都是这个模式用户体系 资源管理 交易/借还记录 预约/审核流程。最后再分享一个我在开发这类项目时的体会不要一上来就想着“功能堆得越多越好”先把“借书、还书、预约、统计”这条主链路跑通再做锦上添花的功能。主链路不扎实加再多花活都是空中楼阁。这个项目从数据库设计到前后端联调再到上线部署整个走一遍下来你对“一个完整系统是怎么诞生的”会有比看任何教程都深刻的理解。如果你正卡在某个环节比如 MyBatis 配置跑不通、Vue3 路由守卫总跳错翻翻上面提到的排查点多半能找到答案。
