
先说明一下我对这类项目的态度图书管理系统是我见过出现频率最高的“学生毕业设计”和“企业练手项目”但大部分人都把它写成了单纯的前后端 CRUD 练习表格一摆、增删改查一跑就宣布完工。真正让它变成“系统”而不是“接口集合”的是权限控制、库存一致性、状态流转这些容易被忽略的细节。这篇文章就围绕我手头这套 SpringBoot Vue3 MyBatis MySQL 的前后端分离图书管理系统展开把从技术选型、数据库设计、接口开发、前端页面到联调排错的全链路思路拆开讲清楚。适合正在做类似管理系统的开发者参考也适合想系统理解这类项目该怎么组织代码的同学。1. 图书管理系统这套“标准款”到底值不值得做先聊技术选型1.1 为什么偏偏是 SpringBoot Vue3 MyBatis 这套组合我见过不少人在选型上纠结很久后端到底是 SpringBoot 还是 SSM前端是 Vue2 还是 Vue3持久层用 MyBatis 还是 MyBatis-Plus数据库要不要换 PostgreSQL。落到图书管理这种业务上答案其实非常明确——SpringBoot Vue3 MyBatis MySQL 就是最稳妥、性价比最高的组合。SpringBoot 负责把后端工程跑起来内嵌 Tomcat简化配置Maven 依赖一拉就能开工省去一整套 XML 配置文件。Vue3 用组合式 API 组织页面逻辑配合 Vite 开发时热更新很快模板语法也比 Vue2 更灵活。MyBatis 作为持久层框架SQL 由自己掌控复杂查询和动态条件都好写相比 JPA 那种全自动 ORM它在“看得见 SQL、可控性高”这件事上更符合这类管理系统的需求。MySQL 就不用多说了中小型业务的数据量用它完全够生态成熟部署运维资料也最好找。有人问为什么不直接上 MyBatis-Plus毕竟单表操作几乎零 SQL。我的观点是基础图书管理系统里完全用 MyBatis 手写 SQL反而能让人更清楚每个查询在做什么联调、排错时也能直接定位到语句。MyBatis-Plus 方便但它把很多细节罩在封装层里出了问题反而多绕一道。项目做到后期、表关系复杂了再考虑引入增强工具也不迟。1.2 前后端分离解决的核心痛点传统 SSM 加 JSP 的方式不是不能用而是当后端写页面、改样式、调逻辑全挤在一个工程里时小项目还好稍微加点功能就乱。前后端分离意味着后端只提供 RESTful 接口和 JSON 数据前端独立成工程负责页面交互。Vue3 工程跑在 5173 端口SpringBoot 接口跑在 8080 端口二者通过 HTTP 通信互不干扰。对我而言分离最大的实际好处是排错边界变清楚了页面显示不对打开浏览器 F12 看请求接口返回异常直接查 SpringBoot 控制台日志。前端和后端可以分别迭代谁也不用等谁编译完。这套项目的整体结构大致如下backend/ src/main/java/com/example/library/ controller/ // 接口层接收请求、返回结果 service/ // 业务层事务、业务规则 mapper/ // MyBatis 持久层接口定义 entity/ // 实体类对应数据库表 config/ // 配置跨域、拦截器、JWT src/main/resources/ mapper/*.xml // SQL 映射文件 frontend/ src/ api/ // 接口请求封装 views/ // 页面组件 router/ // 路由配置 stores/ // 状态管理 components/ // 通用组件前后端分离后的第一个实际感受是后端接口的职责更纯粹了它不需要关心前端用不用这些字段只需要保证数据完整返回、校验严格处理。前端页面也不用再为了某个字段去翻后端模板代码接口文档写好两边各做各的。2. 从需求到落表数据库设计决定后续开发节奏2.1 先分清主流程与非核心模块很多人在设计图书管理系统时上来就建表把借书、还书、图书、读者、管理员、角色、菜单、操作日志一股脑全建出来结果开发时发现大部分表根本用不上还得反复删改。正确的做法是先理清主流程也就是系统最核心的一条操作链路管理员登录后维护图书信息读者可以查询图书、借书、还书系统记录每一次借还历史并能根据图书库存判断是否可借。围绕这条链路核心表就几类图书信息表、图书分类表、读者表、借阅记录表。至于管理员账号、角色权限、操作日志这类属于辅助模块可以后置而且在实际项目中往往让列表查询更复杂优先级不高。我画表结构时习惯先画链路图登录 - 借书 - 检查库存 - 插入借阅记录 - 扣减可借数量 - 还书 - 更新记录 - 增加可借数量。每有一个环节就对应一张表或一个字段这样一步步推表结构不会遗漏也不会冗余。2.2 核心表结构设计以我手头这套项目为例核心表大概是下面这组。图书表要存 ISBN、书名、作者、出版社、分类、馆藏总数、可借数量、存放位置、借阅状态等信息借阅记录表要冗余图书标题、读者姓名等常用查询字段避免每次查记录都关联两张表去 join。表名核心字段说明bookid, isbn, title, author, publisher, category_id, total_count, available_count, location, status图书基础信息库存双字段categoryid, name, description图书分类readerid, reader_no, name, phone, email, status读者信息borrow_recordid, book_id, reader_id, borrow_time, due_time, return_time, status借阅记录与流转状态userid, username, password, real_name, role系统登录账号这里我再解释一下几个关键字段的取舍。图书表的total_count和available_count必须分开一本《Java 编程思想》有 10 册可能只借出了 3 册可借就是 7。如果每次借书都去查借阅记录表里 Count 哪些书还没还查询逻辑和性能都不理想直接维护一个可借数量字段最省事。借阅记录表里我冗余了book_title、reader_name之类的字段虽然违反了严格的数据库范式但在列表页展示时收益非常大——不用 join 两张表就能直接显示书名和读者名查询简单很多。做管理系统不要被“必须满足第三范式”捆住适度冗余是常态。2.3 两个容易被忽略的字段细节第一个是 ISBN 是否作为主键的问题。不要把 ISBN 直接当主键因为同一种书可能有多本ISBN 只能唯一标识一种书不能唯一标识某一本书。我单独用自增 id 做主键ISBN 加唯一索引避免重复录入。第二个是状态字段的约定。status字段在很多表里都有但含义完全不同图书表的 status 标记是否可借读者表的 status 标记是否停用借阅记录的 status 标记的是 in_progress / returned / overdue 三种流转状态。开发时最好提前在项目里建立一个常量类或枚举把所有状态值集中管理否则写到最后代码里全是裸数字维护起来非常痛苦。这一点我在后面联调章节会再展开讲。数据库建好后一定要准备一套完整的种子测试数据。成员多借几次书、书多几个分类、读者有好几种状态前后端联调时才能把列表、详情、借还流程都测到位。空表项目联调时最容易遇到“页面空白但你不知道是前端 bug 还是根本没数据”的情况。3. SpringBoot 后端不只写 CRUD把鉴权和事务放进业务边界3.1 统一返回体和全局异常处理前后端分离后接口的返回结构必须统一否则前端每个页面都要判断不同格式的数据。我习惯用下面这种结构后端无论正常返回还是报错都包装成同一种格式public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(操作成功); r.setData(data); return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.setCode(code); r.setMessage(message); return r; } }接口层的所有 Controller 统一返回 Result前端 axios 拦截器里只要判断code是不是 200 就能决定下一步逻辑。全局异常处理用RestControllerAdvice接住业务异常、参数校验异常和兜底异常避免一长串堆栈直接抛给前端。3.2 JWT 登录认证与接口拦截图书管理系统的用户模型很简单管理员和读者。不同角色能访问的接口不一样管理员可以管理图书、管理读者、查看所有借阅记录读者只能查看图书和个人借阅记录。我用 JWT 做登录认证登录成功后后端签发一个 token前端每次请求在 Header 里带上后端通过拦截器校验 token 并解析出用户角色。选择 JWT 而不是 Session 的原因是这个项目本身就是前后端分离的接口可能被小程序、移动端等多端调用无状态 token 兼容性更好后端集群部署时也不用考虑 Session 同步问题。拦截器实现如下Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); // 解析token校验通过后把用户信息放入request属性 } else { throw new BusinessException(未登录或token已过期); } return true; } }注册拦截器时注意要排除登录接口和静态资源路径否则前端还没登录就会被拦截出现连环 401。角色级别的权限控制可以再做一层简单的拦截判断比如管理员专属接口校验 request 属性里的 role 值。3.3 借书/还书事务与库存一致性借书操作不是简单插入一条借阅记录就完事了要先检查图书是否存在、是否处于可借状态、可借数量是否大于 0然后插入借阅记录同时把图书表的available_count减一。这三个步骤必须在一个事务里执行否则可能出现“记录插了但库存没扣”的数据不一致。Transactional(rollbackFor Exception.class) public void borrowBook(Long bookId, Long readerId) { Book book bookMapper.selectById(bookId); if (book null || book.getAvailableCount() 0) { throw new BusinessException(图书不存在或库存不足); } BorrowRecord record new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setStatus(in_progress); bookMapper.decreaseAvailable(bookId); borrowRecordMapper.insert(record); }这里我直接用Transactional声明式事务rollbackFor 必须指定Spring 默认只对 RuntimeException 回滚如果业务异常是自定义 checked exception不指定 rollbackFor 就不会回滚这是很多人容易踩的坑。还书逻辑类似更新借阅记录状态、归还时间同时把可借数量加回去。事务放到 service 层而不是 controller 层因为 controller 只负责参数接收和响应输出真正的业务规则应该沉淀在 service 里这样同一个借书逻辑无论是 Controller 调用还是别的方法调用都能复用。3.4 MyBatis 动态 SQL 与分页图书列表页的查询条件是典型的多条件组合按书名模糊查询、按 ISBN 精确查询、按分类下拉筛选、按借阅状态筛选。MyBatis 的动态 SQL 在这里非常好用select idselectByCondition resultTypecom.example.library.entity.Book SELECT * FROM book where if testtitle ! null and title ! AND title LIKE CONCAT(%, #{title}, %) /if if testisbn ! null and isbn ! AND isbn #{isbn} /if if testcategoryId ! null AND category_id #{categoryId} /if /where ORDER BY create_time DESC /select动态 SQL 好写但要注意where标签和if条件的配对是否正确最常见的问题是多个条件拼出多余的 AND比如前面条件不满足时直接拼AND status 1。where标签会自动去除开头的 AND但如果在if内部自己手写了 WHERE 关键词就麻烦了建议统一交给where处理。分页我用 PageHelper引入依赖后在 service 查询前调用PageHelper.startPage(pageNum, pageSize)查询结果自动封装成 Page 对象自带 total 字段。PageHelper 底层执行原理是拦截 SQL 自动拼接 LIMIT但要注意它只对紧跟着的第一条查询生效如果查询前做了其他数据库操作分页可能失效。图书管理系统的列表页通常还要展示图书封面图为了快速实现我的做法是封面图只存一个 URL 字符串字段开发时默认给一个占位图地址。如果需要实际上传图片再考虑本地文件存储或云存储这属于扩展功能一开始不用引入太重的东西。4. Vue3 前端组合式 API 写后台管理页面的真实节奏4.1 页面骨架与路由守卫前端工程我用 Vite 创建 Vue3 项目然后装上 vue-router、axios、element-plus 和 dayjs。element-plus 组件库对这个场景特别合适表格、表单、弹窗、分页组件都是现成的能省大量写样式的时间。布局沿用最常见的后台管理结构左侧菜单栏、右侧内容区、顶部用户信息。路由配置时把布局组件设为主布局路由常规页面挂在它的 children 下登录页单独成一条路由。页面跳转权限靠路由守卫控制每次路由变化时检查 localStorage 里有没有 token没有就强制跳转登录页router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.path ! /login !token) { next(/login); } else { next(); } });菜单栏配置我直接写死在路由配置里通过遍历 meta.title 渲染。系统真正复杂到需要动态路由和权限菜单时再考虑后端返回菜单列表前端动态注册路由这套项目暂时不用过度设计。4.2 axios 封装与请求拦截axios 必须统一封装否则每个页面写一遍重复的逻辑。我在 src/api/request.js 里导出一个配置好的实例写入 baseURL 为http://localhost:8080/api请求拦截器负责把 token 塞进 Header响应拦截器统一处理业务状态码service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; }); service.interceptors.response.use( response { const res response.data; if (res.code 200) { return res; } else if (res.code 401) { localStorage.removeItem(token); router.push(/login); return Promise.reject(new Error(登录过期)); } else { return Promise.reject(new Error(res.message)); } }, error { return Promise.reject(error); } );这里要提一下跨域问题在开发阶段用 Vite 的 proxy 解决的话前端请求的 baseURL 可以直接写成相对路径/api由 proxy 转发到后端 8080这样就不会被浏览器同源策略卡住。生产环境用 Nginx 反向代理规则类似。具体配置我在下一章节专门说明。4.3 核心页面怎么组织图书列表页是这个系统的重头戏。页面上方是搜索条件区下方是表格和分页我直接上 element-plus 的 el-table 和 el-pagination数据加载函数封装在 composable 里。搜索按钮触发查询时把 form 里的搜索条件复制一份传给后端查询接口同时把分页 current 重置为 1否则可能出现“在第 5 页点搜索结果列表为空但页码没变”的怪现象。新增和编辑图书用同一个弹窗表单通过一个dialogVisible布尔值加上一个currentRow对象控制。新增时 currentRow 为初始空对象编辑时从表格行数据赋值过来保存时根据是否携带 id 自动判断走新增接口还是更新接口。这个逻辑模板化程度很高所有管理页面都可以复用。借书还书的交互我做成表格行内按钮。借书按钮点击后弹窗选择读者后端去校验读者状态和图书剩余量返回成功后刷新列表还书按钮类似确认后把对应借阅记录的状态改成已还。前端不复杂真正的业务逻辑都在后端 service 里这样接口即便被多端调用规则也不会跑偏。4.4 状态显示与时间格式化的处理图书状态、借阅状态这些字段后端存的基本都是字符串或数字前端显示需要转成用户能看懂的文字和标签。我的做法是写一个lib/constants.js文件集中导出状态映射export const BOOK_STATUS { available: { label: 可借, type: success }, borrowed: { label: 借出, type: warning }, off_shelf: { label: 下架, type: info } };表格里用 el-tag 渲染status 字段的值映射成展示文本和颜色。这样后端返回什么值前端只需要查表显示不会出现页面上到处散落着status 1 ? 可借 : 不可借这种魔法判断。时间格式化是另一个高频需求后端返回的日期时间默认可能是yyyy-MM-dd HH:mm:ss字符串也可能是一串时间戳统一用 dayjs 格式化处理。日期范围选择器传到后端的参数我直接传字符串数组后端用RequestBody接收再解析为时间区间进行 SQL 查询。5. 前后端联调绕不开的五个坑以及我的排查顺序5.1 跨域配置开发阶段到底该谁出手前后端分离第一个遇到的坑就是跨域。前端 5173 端口请求后端 8080 端口浏览器的同源策略会直接拦截。解决方式有两种后端写跨域配置或者前端利用 Vite proxy 做代理转发。我推荐的做法是开发环境用 Vite proxy这样前端代码里请求的 baseURL 用相对路径/api代理配置在vite.config.jsserver: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样做的好处是浏览器请求的地址是http://localhost:5173/api/xxx没有跨域问题。后端不用单独配跨域也能完成开发联调。生产环境部署时前后端由 Nginx 统一代理同样是相对路径转发不再有跨域概念。如果项目里有部分接口要提供给外部系统调试或者前端直接请求后端地址那后端需要配 CORS。SpringBoot 里最直接的方式是加一个 WebMvcConfigurer 配置类允许指定来源访问注意不要把allowedOrigins配成*同时还要带 cookie 之类的情况否则会握手失败。5.2 日期时间格式化不一致联调时最容易在大晚上看到的问题就是时间字段显示异常。常见表现是前端拿到的日期是1720000000000这样的一串数字或者后端返回2025-07-14T10:30:00这种带 T 的格式跟页面上其他时间的展示风格不一致。出现这个问题的原因在于 SpringBoot 默认序列化时间字段的规则和前端期望的格式不一定一致。统一处理后端在 application.yml 里配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8同时实体类的日期字段上还可以用JsonFormat(pattern yyyy-MM-dd HH:mm:ss)单独标注双保险。前端拿到字符串格式之后直接用 dayjs 处理展示不再担心时区偏移。排查这类问题有个技巧先看浏览器 Networks 面板里接口返回的原始 JSON确认后端输出的格式再分析是传输层的问题还是前端格式化层的问题不要一上来就改代码。后端返回什么字段就先在前端控制台 console 打印出来一步步缩小范围。5.3 路径参数和请求方式不匹配联调时另一类高频问题是接口明明存在但请求 404 或 405。404 通常是路径对不上405 是请求方式不对。我后端按 RESTful 风格设计接口比如借阅记录查询是GET /api/borrowRecord/list新增图书是POST /api/book/add更新是PUT /api/book/update。如果前端用 POST 去调 update 接口SpringBoot 路由匹配不上就会返回 405。排查步骤很简单先看接口报错的 HTTP 状态码404 就检查 URL 拼写和 SpringBoot 的 context-path 前缀405 就检查方法是否对应。还有一个细节我提了多次也不嫌多就是后端口方法上的RequestMapping路径不要带前导或尾随斜杠的重复节点比如类上写了/api/book方法上就别再写/api/book/add否则拼起来变成/api/book/api/book/add。5.4 分页参数和 total 字段的前后端认知差异分页联调是图书列表出问题最多的地方。后端我用 PageHelper返回的数据结构是{ records: [...], total: 100, current: 1, size: 10 }前端 el-pagination 期望的却是total和pageNum/pageSize。这里最直接的坑是前端组件用的参数名和后端返回的字段名对不上导致列表数据正常但分页条显示只有一页或一直不刷新。我的做法是接口返回结构保持后端规范前端在响应拦截器拿到数据后统一做一个转换或者列表页面拿到数据后手动解构出 records 和 total再赋给 el-pagination。分页组件点击事件里的参数名是current和size传给后端时要映射成pageNum和pageSize这些细节不统一就会出现“列表一直第一页”“点击第 2 页没反应”之类的 bug。5.5 借阅状态流转的混乱状态机约定大于联调最后我要重点说状态机问题。图书系统里借阅记录的状态字段我定了多种初始是in_progress借阅中正常归还变成returned已归还超过应还日期还是in_progress但在前端根据 due_time 判断是否逾期显示红标。后端还书接口只认in_progress状态的记录如果前端借阅列表里点击“还书”传过来的记录已经是returned后端要给出明确提示而不是静默失败。这类问题的根源是前后端对状态的定义没有统一规范。我在项目里用后端 enum 类和前端 constants.js 同时维护一份状态常量注释写清楚每个状态触发的条件和变化方向。联调时一旦发现状态显示不对先对比两边的常量值 —— 我踩过几次“后端状态是 returned前端判断的是 RETURNED”这种大小写不一致的坑统一约定之后这类问题基本绝迹。最后分享一个我自己的体会开发这类管理系统写代码的时间大约只占一半另一半都花在前后端配合的细节上。数据库设计阶段多花一小时把字段定义清楚、状态常量约定好后面联调阶段就能少熬几晚。如果你正准备动手做一套这样的系统强烈建议先从数据表和状态常量开始而不是急着写页面。这个项目后续要做扩展方向也很明确用 Spring Security 替代手写拦截器、引入 Redis 缓存热门图书列表、借阅到期做定时任务提醒甚至接入微信通知。底层架构只要保持前后端分离、职责清晰的状态这些扩展都能平滑接入不会伤筋动骨。