先交代个项目背景:这个源码项目是我在实际带团队时,为一个高校图书馆做的“疫情常态化下的预约借阅管理系统”。技术栈就是标题里那套——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`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;预约表是疫情场景下新增的核心表,它记录了“哪位读者预约了什么时间段,或者预约了哪本书”:
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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表在设计时有一个取巧之处:用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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;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、isbn,t_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 Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }配合一个@RestControllerAdvice全局异常处理类,将业务异常、参数校验异常、数据库异常统一封装成上述结果返回。这样做的好处,前端 Axios 响应拦截器只需判断code === 200走成功逻辑,其他统一走报错提示,不用每个接口单独处理错误分支。
3.3 MyBatis 动态SQL与分页查询实践
MyBatis 的精髓在 XML 里的动态 SQL。图书搜索这个功能,请求参数可能是书名 + 分类 + 是否仅看可借,甚至可能什么都不传。用动态SQL逐个拼接条件是最合适的写法:
<select id="selectBookPage" resultType="com.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 test="bookName != null and bookName != ''"> AND b.book_name LIKE CONCAT('%', #{bookName}, '%') </if> <if test="categoryId != null"> AND b.category_id = #{categoryId} </if> <if test="onlyAvailable != null and onlyAvailable == true"> AND b.available_stock > 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 vite@latest library-frontend -- --template vue cd library-frontend npm install npm install vue-router@4 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 :model="searchForm" inline> <el-form-item label="书名"> <el-input v-model="searchForm.bookName" placeholder="请输入书名" clearable /> </el-form-item> <el-form-item label="分类"> <el-select v-model="searchForm.categoryId" placeholder="请选择分类" clearable> <el-option v-for="item in categoryList" :key="item.id" :label="item.categoryName" :value="item.id" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="handleSearch">查询</el-button> <el-button @click="resetSearch">重置</el-button> </el-form-item> </el-form> <el-table :data="bookList" v-loading="loading" border stripe> <el-table-column prop="bookName" label="书名" min-width="180" show-overflow-tooltip /> <el-table-column prop="author" label="作者" width="120" /> <el-table-column prop="categoryName" label="分类" width="100" /> <el-table-column prop="totalStock" label="馆藏" width="80" /> <el-table-column prop="availableStock" label="可借" width="80" /> <el-table-column label="操作" width="180" fixed="right"> <template #default="{ row }"> <el-button type="primary" link @click="handleBorrow(row)">借阅</el-button> <el-button type="warning" link @click="handleReserve(row)">预约</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="pageNum" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" /> </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 ref="formRef" :model="form" :rules="rules" label-width="80px"> <el-form-item label="书名" prop="bookName"> <el-input v-model="form.bookName" /> </el-form-item> </el-form>const 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] Description=Library Server After=network.target [Service] User=root WorkingDirectory=/opt/library ExecStart=/usr/local/java/bin/java -jar /opt/library/library-server.jar --spring.profiles.active=prod Restart=on-failure RestartSec=5 [Install] WantedBy=multi-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://...后加serverTimezone=Asia/Shanghai |
| 前端请求返回 401,但登录接口正常 | token 过期或拦截器未放行该接口 | 检查拦截器排除路径配置 |
| 插入的记录中文乱码 | 数据库连接未指定字符集 | JDBC 连接串加characterEncoding=utf8 |
| 分页查询总数不对 | PageHelperstartPage后还有别的查询 | 确保startPage紧跟目标查询 |
| 前端刷新页面后 404 | Nginx 未配置try_files | 补上try_files $uri $uri/ /index.html; |
| 本地联调正常,服务器请求超时 | 服务器防火墙未放行端口 | 开放 80 和 8080 端口,或使用 Nginx 统一代理 |
| 删除图书后历史记录查不到书名 | 物理删除导致外键数据丢失 | 改用逻辑删除,status=0表示下架 |
构建前端时提示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 路由守卫总跳错,翻翻上面提到的排查点,多半能找到答案。