图书管理系统在Java课程设计里的地位基本上相当于前端圈的待办事项应用——每年都会有一堆人做也都知道怎么做但真正能把结构写干净、将来答辩时能讲清楚每一层在干什么的反而不多。我最近完整实现了一套基于SpringBoot Vue的图书管理系统前端用Vue后端SpringBoot持久层MyBatis数据存MySQL代码从零手写没有套用网上那种“一个Controller写到底”的模板。这套系统覆盖图书录入、分类管理、读者管理、借书还书、超期计算、库存扣减和基本统计既能拿来交课设也能作为前后端分离项目的入门范本。以下把整个设计和实现过程完整拆解出来包括技术选型理由、表结构设计、核心功能代码、分页插件用法、常见坑点和部署经验想动手复现的同学可以全流程跟着走。1. 项目整体设计与技术选型1.1 系统定位这套图书管理系统到底做了什么先想清楚一个问题市面上图书管理系统源码那么多为什么还要自己写一套我的判断是绝大多数课设项目功能堆得太多但业务闭环不完整。所谓业务闭环就是一条读者从“登录”到“查书”再到“借书”“还书”的完整链路每一步都有数据落库每一步都有状态变化。所以我这个系统的定位非常明确管理员负责图书、分类、用户管理普通用户登录后可以浏览图书、查询自己的借阅记录、发起借阅、续借和归还。管理员可以看到全部借阅流水也能在后台看到图书库存情况。角色通过用户表中的role字段区分前端根据角色渲染不同菜单后端接口做了权限校验避免普通用户拿到管理接口。功能清单可以梳理成三大块。图书维度新增、编辑、删除、条件搜索书名、作者、ISBN、分页展示。借阅维度借书、还书、续借、超期天数自动计算、借阅记录查询。系统维度用户登录、管理员对读者账号的启停用、分类维护。功能数量不多但每一条业务都走通了并且数据库层有对应字段去承载这比堆十几个“只读不写”的功能模块更有价值。1.2 技术栈选型为什么偏偏是这四个SpringBoot负责后端基础框架理由很直接——配置简单、内置容器、生态成熟。一个图书管理系统用传统SSM写要单独配置Spring、SpringMVC、MyBatis的数据源、事务管理器、扫描路径还要手动部署到Tomcat换成SpringBoot之后一个main方法启动内嵌Tomcat连数据源、事务、MyBatis的装配都可以靠starter自动完成。对于开发经验和经验都不算多的学生来说SpringBoot把项目“能跑起来”的门槛降到了最低。Vue负责前端页面选出它的关键原因是组件化和响应式。图书列表、表单弹窗、分页组件都可以拆成独立组件复用数据变化时页面自动更新不需要像JQuery时代那样手动操作DOM。现在人际关系中前端最常用就是Vue或React用Vue做图书管理系统后续如果想学Element Plus或尝试后端模板引擎之外的路子这一套经验可以平滑迁移。MyBatis做持久层核心卖点是SQL可控。有人会问为什么不用MyBatis-Plus我的回答是MyBatis-Plus虽然写基础CRUD更快但很多课设项目用着用着就只会调BaseMapper了连SQL都不会手写。图书管理系统的查询条件复杂书名模糊搜索、多条件组合、关联查询借阅记录这些用MyBatis手写动态SQL反而更清晰。而且如果将来面试被问“MyBatis分页插件实现原理”“#{}和${}的区别”自己手写过一遍答起来会扎实很多。MySQL存数据则是稳定和普及度决定的。书籍、用户、借阅记录这些数据都有明确的关系用关系型数据库来处理天然合适。MySQL免费、安装简单、资料齐全哪怕是在Linux服务器上部署也有大量踩坑文章可以参考对课设项目来讲是最稳的盘。1.3 工程设计与目录结构后端我按标准的三层架构来分层Controller处理请求参数和响应Service封装业务逻辑Mapper负责数据库操作。实体类单独放在entity包里统一的返回结果和异常处理放在common包工具类放utils包配置类放config包。单看包结构就能知道这个项目的层次是清晰还是混乱。com.example.library ├── common // Result统一返回体、异常处理器 ├── config // CORS跨域、拦截器注册、MyBatis配置 ├── controller // 图书、用户、借阅、分类相关接口 ├── entity // 表对应的实体类 ├── mapper // MyBatis Mapper接口 ├── service // 接口 实现类 └── utils // JWT工具类、日期工具类前端用Vue3 Vite Vue Router Pinia Element Plus目录按用途拆src ├── api // 每个模块的请求方法如book.js、borrow.js ├── router // 路由表与导航守卫 ├── stores // Pinia状态管理如userStore ├── views // 页面组件如BookList.vue、Login.vue ├── components // 可复用的业务组件如Pagination.vue └── utils // axios实例、token工具前后端分离除非直接部署在同一域下否则必须解决跨域问题。我在后端写了CORS配置允许所有来源前端开发环境再配一层Vite代理双保险避免联调时被浏览器跨域策略卡住。这些细节后面单独讲。2. 数据库设计先把表结构想清楚2.1 核心业务表设计写代码之前一定要先把表建好。图书管理系统不需要太复杂的表四张核心表足够支撑整个系统图书表、分类表、用户表、借阅记录表。很多初学同学喜欢把“借阅记录”拆成“借阅表”和“归还表”这是过度设计课设项目用一张借阅状态表就能表达清楚。表名核心字段作用t_bookid, isbn, book_name, author, publisher, category_id, price, stock, stock_remaining, status图书信息与库存t_categoryid, category_name, sort_order图书分类t_userid, username, password, real_name, role, status, phone, email管理员与普通用户t_borrow_recordid, user_id, book_id, borrow_date, due_date, return_date, status, renew_count借阅流水与状态用户表的role字段用简单字符串区分ADMIN和USER。这样前端根据角色控制菜单后端在拦截器里判断角色权限两者配合就能让普通用户进不了管理接口。用户密码必须存加密后的密文我用的是BCryptPasswordEncoderSpring Security并不需要整体引入只引入spring-security-crypto依赖就可以拿到这个工具类成本很低。借阅记录表是业务最重的一张表。status字段用数字表示0代表正在借阅1代表已归还2代表已超期。这里的超期不是靠定时任务去刷的而是在查询借阅记录时实时计算due_date和当前日期差值一旦差值大于0且status为0就在返回接口的时候把超期天数算出来并把状态动态标记为已超期。这样不用写定时器数据也不容易脏。2.2 字段设计与关键约束图书表里我把isbn设置成了唯一索引。为什么不拿图书名做唯一因为同名不同版本书很多但ISBN是全球统一的图书编码能精确到一套书。唯一索引能防止管理员不小心把同一套书重复录入。库存字段我拆了stock总库存和stock_remaining剩余可借库存每次借书扣减stock_remaining还书加回stock_remaining总库存保持不动这样可以方便查看“有多少本书正被借走”。借阅记录表的核心约束是同一用户同一本书当前只能有一条“正在借阅”的记录。这个约束我用的是MySQL5.7以上版本支持的部分唯一索引即status为0时user_id和book_id组合唯一。如果没有这个约束用户在页面上快速点两次“借阅”就会插入两条脏数据。如果数据库版本不支持部分唯一索引也可以在Service层先查一下当前是否已有未还记录用代码兜底。用户表的status字段默认值为0。这个点对应很多搜索引擎里常出现的“mysql设置默认值为0”建表语句里直接写DEFAULT 0插入用户时如果不显式传status自然就是启用状态。还有password字段我设置了“必填但不在列表接口返回”查询用户时单独写SQL把password列为空。2.3 初始化SQL与兜底数据建表脚本里我习惯把所有库表结构写在init.sql中并在初始化数据中加入一个默认管理员账号用户名admin、密码使用BCrypt生成的密文。这个细节很关键因为很多源码给的是明文密码一旦用加密逻辑去校验就会直接登录失败。初始化数据不需要太多每个分类放两条图书数据就够了保证页面一打开就有内容可看。SQL脚本中的核心建表语句长这样CREATE TABLE t_book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) UNIQUE NOT NULL, book_name VARCHAR(100) NOT NULL, author VARCHAR(50), publisher VARCHAR(100), category_id BIGINT, price DECIMAL(10,2) DEFAULT 0.00, stock INT DEFAULT 0, stock_remaining INT DEFAULT 0, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );图书表的status直接用TINYINT1代表上架0代表下架。下架的书不能被借阅但保留在库中这样管理员可以下架库存档的书而不会连历史借阅数据一起消失。分类和图书是父子关系删除分类前要检查该分类下是否还有图书有就阻止删除避免关联数据变成孤儿。3. 后端实现SpringBoot MyBatis核心代码3.1 项目骨架与基础依赖配置后端直接用Spring Initializr生成或者自己建Maven工程都行。依赖方面不需要把Spring全家桶全部引进来否则不仅启动慢配置也复杂。我的pom.xml核心依赖就四个SpringBoot Web、MyBatis Spring Boot Starter、MySQL驱动、PageHelper分页插件。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependencyapplication.yml配置里除了端口和数据源我建议把MyBatis的XML扫描路径和日志打印都打开。spring: datasource: url: jdbc:mysql://localhost:3306/library_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.library.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case可以让我们把数据库的book_name自动映射为实体类的bookName省去一堆resultMap。这个配置建议牢记很多新手没开这个开关查询结果全是null第一反应竟然是找SQL的问题。3.2 MyBatis的两种写法注解与XML怎么配合MyBatis支持注解SQL和XML SQL两种方式。我的原则是单表的简单CRUD用注解复杂动态查询和关联查询用XML。注解的优势是代码紧凑一眼能看出逻辑。比如分类表Mapper public interface CategoryMapper { Select(SELECT * FROM t_category ORDER BY sort_order) ListCategory findAll(); }图书表这种多条件组合查询放在XML里更合适。条件可能是书名模糊、ISBN精确、分类精确还有可能都不传这时动态SQL非常重要select idsearchBooks resultTypecom.example.library.entity.Book SELECT * FROM t_book where if testbookName ! null and bookName ! AND book_name LIKE CONCAT(%, #{bookName}, %) /if if testisbn ! null and isbn ! AND isbn #{isbn} /if if testcategoryId ! null AND category_id #{categoryId} /if AND status 1 /where ORDER BY id DESC /selectwhere标签的妙处在于它会自动去掉SQL中第一个多余的AND避免出现WHERE AND book_name LIKE这种语法错误。为什么用CONCAT(%, #{bookName}, %)而不是%${bookName}%因为#{}会走PreparedStatement参数占位能有效防SQL注入${}则是字符串拼接一旦bookName里传了单引号或关键字后果很严重。3.3 PageHelper分页插件正确用法和典型坑分页是管理系统的标配图书列表、借阅记录都得翻页。PageHelper是国人开发的分页插件原理是拦截MyBatis执行器在查询前自动拼接LIMIT语句。用法很简单PageHelper.startPage(pageNum, pageSize); ListBook books bookMapper.searchBooks(book); PageInfoBook pageInfo new PageInfo(books);调用完PageHelper.startPage(pageNum, pageSize)之后紧接着执行的第一条MyBatis查询会被分页这是使用该插件最重要的认知。有人喜欢在Service里先查一个分类再查图书列表把startPage放在最前面结果分页跑到分类查询上去了图书反而没有被分页。正确做法是startPage紧贴着目标查询写中间不要插入其他数据库操作。还有一个隐藏问题如果你代码里已经在手写LIMIT又调用了PageHelper会出现SQL里有两个LIMIT直接报错。所以用了分页插件Mapper里的查询就不要自己拼LIMIT了。分页返回给前端的数据结构PageInfo里包含了total、pageNum、pageSize、list等字段前端分页组件直接取这些字段用即可。我习惯在统一返回体里封装一个PageResult避免把PageInfo整个返回去把一些无关字段暴露给前端。3.4 Service层事务与借还书核心逻辑借书流程看起来简单查库存、减库存、插入借阅记录。但这两个操作如果不在一个事务里减库存成功而插入记录失败就会造成库存不对。所以Service方法需要加Transactional注解。Transactional(rollbackFor Exception.class) public void borrowBook(Long userId, Long bookId) { Book book bookMapper.selectById(bookId); if (book null || book.getStatus() ! 1) { throw new BizException(图书不存在或已下架); } if (book.getStockRemaining() 0) { throw new BizException(库存不足); } int updated bookMapper.decreaseStock(bookId); if (updated 0) { throw new BizException(库存扣减失败); } BorrowRecord record new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setBorrowDate(LocalDate.now()); record.setDueDate(LocalDate.now().plusDays(30)); record.setStatus(0); borrowRecordMapper.insert(record); }注意这里的decreaseStock我写成了UPDATE t_book SET stock_remaining stock_remaining - 1 WHERE id #{id} AND stock_remaining 0。用这条带条件的更新语句可以在数据库层规避并发问题。如果有两个用户同时借同一本书理论上库存只剩1本两个请求都先查到了剩余库存为1然后同时执行扣减普通写法会都成功库存变成-1带AND stock_remaining 0的写法只会有一个请求更新成功另一个更新影响行数为0随后抛出业务异常。这也是应付“并发库存扣减”面试题的标准答案。还书逻辑里最重要的点是超期天数。我不用定时任务直接在查询借阅记录时实时计算public long calculateOverdueDays(BorrowRecord record) { if (record.getStatus() ! 0) { return 0L; } LocalDate dueDate record.getDueDate(); return LocalDate.now().isAfter(dueDate) ? ChronoUnit.DAYS.between(dueDate, LocalDate.now()) : 0L; }续借操作限制最多续借一次先把due_date往后推15天再把renew_count加1如果当前状态不是正在借阅直接提示不能续借。由于所有操作都在Service层完成后端逻辑条理清晰不像把业务写进Controller那样会乱成一锅粥。3.5 统一返回体和全局异常处理联调时前端最怕后端接口一会儿返回{code:0, data:{}}一会儿又返回{success:true, result:[]}格式不统一加大前端判断难度。我从一开始就定了统一返回体public class ResultT { private Integer code; // 200 成功500 失败 private String message; private T data; }Controller的每个接口都返回Result。对于业务异常我自定义了BizException并写了一个全局异常处理器用RestControllerAdvice捕获。这样一来Service里抛出new BizException(图书不存在)前端拿到的响应就是HTTP 200但code500message是业务提示。HTTP状态码只用于底层错误业务错误统一走业务状态码。这个方法可以避免把业务异常信息直接暴露成HTTP错误也方便前端用统一逻辑弹提示。4. 前端Vue实现与前后端联调4.1 环境准备与Vite项目初始化前端我选的是Vue3 Vite而不是Vue2 Vue CLI。Vite启动速度快热更新体验好目前的生态也完全成熟。初始化项目npm create vitelatest library-frontend -- --template vue cd library-frontend npm install npm install axios vue-router pinia element-plus这里有一个容易踩的坑Element Plus是完整引入还是按需引入。我的建议是课设项目直接完整引入不用折腾unplugin-auto-import和unplugin-vue-components按需插件。完整的写法是import ElementPlus from element-plus import element-plus/dist/index.css app.use(ElementPlus)别因为省一点打包体积就去踩按需引入的坑课设规模的项目完整引入只是在启动时体积稍大一点开发效率高得多。包里还要装一个element-plus/icons-vueElement Plus的菜单和按钮图标都依赖这个包。4.2 路由与页面结构路由采用Vue Router注意每个页面都用懒加载方式引入component写成箭头函数返回import结果这样首屏加载不会把所有页面代码全打包进去。const routes [ { path: /login, component: () import(/views/Login.vue) }, { path: /, component: () import(/layout/MainLayout.vue), redirect: /books, children: [ { path: books, component: () import(/views/BookList.vue) }, { path: categories, component: () import(/views/CategoryManage.vue) }, { path: borrow, component: () import(/views/BorrowRecord.vue) }, { path: users, component: () import(/views/UserManage.vue) } ] } ]布局组件MainLayout是核心壳子包含侧边栏菜单、顶部栏和路由出口。菜单项根据用户角色动态渲染管理员可以看到用户管理普通用户只看到图书浏览和个人借阅记录。这个判断在Pinia的userStore里存了用户信息登录后拉取一次刷新时再根据本地缓存的token调一次接口恢复用户状态。比较典型的Vue Router参数使用场景是编辑图书时跳转路由带上id参数router.push(/books/edit?id12)或者用/books/edit/12路径传参然后在详情页通过route.query或route.params接收。4.3 Axios封装与跨域处理前端不可能在每个页面里都写axios.get(url)。我统一封装了一个axios实例设置baseURL和请求拦截器。请求拦截器负责从本地存储拿token并放到请求头响应拦截器负责统一解包Result结构。这样页面里只需要const res await bookApi.searchBooks({ bookName, pageNum })请求拦截器里如果发现本地没存token直接跳转登录页响应拦截器如果发现code!200就用Element Plus的ElMessage弹出错误提示。开发环境的跨域需要处理。有两个常见做法一是在Vite的vite.config.js里配proxy二是在SpringBoot里放行CORS。我两个都做了server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }后端也有对应的跨域配置类实现WebMvcConfigurer接口重写addCorsMappings。这样无论是用前端代理还是直接访问后端接口都不会被跨域卡住。4.4 典型页面实现思路图书列表页是系统里最有代表性的页面顶部是搜索条件区中间是表格区底部是分页区。搜索条件绑定到响应式对象点“搜索”按钮时重新调接口并回到第一页。表格核心列包括书名、作者、分类、价格、库存、状态操作列嵌入“编辑”“下架”“借阅”按钮。新增和编辑图书用一个Dialog弹窗实现内部放一个表单规则校验单独配置。提交时把表单数据POST到后端成功后关闭弹窗并刷新列表。这种“列表页 弹窗表单”的组合在管理系统中非常通用做好这一个页面后续用户管理、分类管理都是复制改写的活。借阅记录页用卡片或表格展示记录列表每条记录显示书名、借出日期、应还日期、状态和超期天数。还书按钮调后端的归还接口。前端在模板里直接计算超期天数不是好方案因为前端时钟不可信应该等后端算好超期天数后直接渲染。这一点在写接口时要注意后端返回给前端的数据模型里已经有overdueDays字段了前端只管展示。5. 登录鉴权与接口安全5.1 JWT登录认证与拦截器图书管理系统不需要复杂的Spring Security权限模型JWT 拦截器足够。登录接口校验用户名密码成功则生成token返回给前端前端存到localStorage每次请求放在Authorization头里。public String generateToken(User user) { return Jwts.builder() .setSubject(user.getId().toString()) .claim(role, user.getRole()) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000L)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }后端拦截器解析token从token里拿到userId和role。对于/api/admin/**前缀的接口拦截器除了校验token有效性还需要校验role是否等于ADMIN。登录接口和图书浏览接口可以匿名访问所以注册拦截器时要配置excludePathPatterns。一个实践技巧token过期时间设7天前端可以在响应拦截器里捕获401状态并跳转登录页。过期后用户体验是“刷新页面被踢回登录页”这其实是正常表现我在很多项目里看到有人为了逃避这个问题设置30天过期安全隐患反而更大。5.2 输入校验、SQL注入与XSS防护后端不能用“前端已经校验过了”来给自己找借口接口一旦暴露任何人可以绕过前端直接发请求攻击。我在后端实体对象的表单校验字段上加了javax.validation注解比如NotBlank(message 书名不能为空)、Min(value 0, message 库存不能为负数)Controller参数上加Validated触发校验。前端也做好双重校验后端和前端校验规则保持基本一致避免出现“前端提示成功、后端返回失败”的体验差异。SQL注入防护方面MyBatis的#{}天然防注入但${}不会。我整个项目里没有出现${}只用了#{}。XSS方面图书说明信息这类可能出现特殊字符的字段提交到后端后在插入数据库前统一过滤script等危险标签。如果你要做一个更完整的方案可以写一个基于HandlerInterceptor的全局过滤器对请求体中的文本字段做清洗不过课设项目先把前端输入框的校验和后端#{}用足风险已经很小。5.3 部署上线后端打包、Linux安装MySQL、Nginx托管开发完成后部署环节要能折腾得起。后端打成可执行jar包mvn clean package -DskipTests java -jar library-server-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod数据库在服务器上建议用Linux安装MySQL网上教程很多但我总结最容易踩的三个坑一是安装后默认root密码不好找可以通过临时日志查看二是字符集一定要设为utf8mb4否则中文插入后变成问号三是远程连接需要单独授权默认root只允许本机localhost登录。为了防止踩坑我一般用Docker跑MySQLdocker run -d --name mysql \ -e MYSQL_ROOT_PASSWORD123456 \ -e MYSQL_DATABASElibrary_db \ -p 3306:3306 \ mysql:8.0 --character-set-serverutf8mb4前端dist目录放到Nginx的html目录下再配置一个location把/api反向代理到后端jar的8080端口。这样前端和后端就在同一个域名下不存在跨域且Nginx可以承担静态资源的高并发访问。数据库脚本先导入再启动后端最后起Nginx三个进程各自独立重启更新都非常方便。6. 常见问题与排查技巧实录6.1 环境与依赖类问题我实际跑这个项目时最先遇到的是数据库连接失败错误里带Public Key Retrieval is not allowed。这是MySQL8的一个安全设置解决方法是在JDBC连接串上加上allowPublicKeyRetrievaltrue。另一个高频问题是驱动类名老版本驱动是com.mysql.jdbc.DriverMySQL8之后必须用com.mysql.cj.jdbc.Driver。这类报错直接搜索错误信息的前几个单词就能锁定问题不用乱试。SpringBoot版本和MyBatis starter的兼容性也要注意。如果用了SpringBoot 3.xMyBatis需要最新版本才能兼容而且包名从javax换成了jakarta。我自己更喜欢用SpringBoot 2.7.x做课设稳定、教程多、PageHelper兼容性好不需要折腾版本问题。6.2 MyBatis相关典型问题分页不生效是出现频率最高的问题。原因无非三种第一种是PageHelper.startPage和查询语句中间隔了其他SQL操作导致分页拦截到错误的语句第二种是返回类型用了ListBook而不是PageInfo直接把分页信息丢掉了第三种是查询方法本身在XML里手写了LIMIT两套分页逻辑冲突。排查时看控制台打印的SQL如果SQL里没拼LIMIT就说明startPage没能覆盖到这条查询。MyBatis缓存也需要了解。一级缓存默认开启同一次SqlSession内重复查询会命中缓存但这个SqlSession在Spring事务环境中是跟着事务走的事务结束缓存就被销毁。二级缓存默认不开启开启后是namespace级别的会把整个Mapper的查询结果缓存下来。我在这个项目里特意关闭了二级缓存因为借阅记录和图书库存是高频更新数据缓存不仅收益低还容易在更新后读到旧数据。如果非要用缓存优化热点图书查询应该用Redis做业务级别的缓存并且给出明确的失效策略。还有一个小坑XML文件待在src/main/java下导致编译后target目录找不到XMLMyBatis启动报“Invalid bound statement”。解决办法是在pom.xml里显式声明resources包含classpath:mapper/*.xml或者把XML都放在src/main/resources/mapper下。项目里我也打印了MyBatis日志通过log-impl配置让日志输出真实SQL一旦接口返回数据和预期不一致先看SQL对不对。6.3 前端联调与开发问题前端最烦人的就是接口请求跨域。如果你是开发环境并且已经配置了Vite proxy还是报跨域多半是因为请求地址写成了http://localhost:8080/api/books这种完整地址而代理只对/api开头的路径生效。理想做法是axios的baseURL写成/api请求直接发到/api/books让Vite代理转发到后端。还有一个容易被忽略的问题是浏览器本地存的旧token后端改了密钥后旧token全部失效每次请求返回401清理一下localStorage再重新登录就好。Element Plus表格显示不出数据也要分类排查。如果接口返回有数据但表格空白很可能是数据字段名对不上后端返回的是book_name前端写的prop是bookName。我的做法是后端统一开启map-underscore-to-camel-case保证返回JSON字段名与前端定义一致这个坑就从根本上消失了。7. 项目扩展方向与我的实操心得7.1 从课设到生产级项目还可以怎么扩展这套系统目前完整跑通但如果想在简历上写得更扎实有几个方向值得投入。第一个是引入Redis作为缓存层把热门图书的查询结果缓存起来同时用Redis做借阅记录的分布式锁防止并发借书时库存扣多。第二个是增加消息通知借书成功、图书即将到期时给用户推送提醒这个功能在课设阶段不太需要但作为扩展点可以讲清楚方案选型。第三个是丰富统计维度比如借阅排行榜、分类借阅占比、每月新增图书趋势前端用ECharts画图表页面瞬间会高级很多。后端层面还可以把Security换成Spring Security或Sa-Token权限管理更规范数据库层可以引入Flyway管理数据库版本部署层面可以用Docker Compose把MySQL、后端、Nginx编排成一套完整服务。每一个扩展点都值得在答辩中展开讲它们说明的不只是“能做功能”更是“知道系统上线后会面临什么问题”。7.2 我自己实际跑通这个项目后的几点体会最后一小段不写总结了聊点实际操作层面的体会。第一次完整跑通这套系统时最大感受是前后端联调一定要尽早开始不要等后端全部写完才写前端。两边接口一旦定死谁先开发都能并行推进。接口字段命名最好在一开始就定成驼峰风格后端、数据库、前端三处对应关系一次想清楚后面改名的成本极高。还有一点是错误提示设计要面向用户不是面向开发者。后端抛异常时不要直接返回“SQL执行失败”而是要转成“系统繁忙请稍后再试”并把具体日志留在后端控制台。业务提示如“库存不足”“该书已借出”用户能看懂但“NullPointerException”之类用户看不懂还会暴露系统内部细节。图书管理系统虽然不算大型项目但麻雀虽小五脏俱全从数据库设计到权限控制到部署覆盖了企业开发的完整链路。我建议拿到源码的同学不要只盯着“能不能跑”先按自己的理解画一遍表关系再对着接口清单看每个接口怎么流转。改需求不可怕可怕的是库存扣了借阅记录没插进去读者还了书但系统还显示没还——通过BookMapper的decreaseStock(bookId, 1)条件更新配合事务和状态机字段这种数据不一致问题在这个系统里已经被堵死了把它讲明白比多写十个接口都值。