图书管理系统大概是Java开发圈子里最常见的实战项目了——校园毕设、培训机构作业、新人练手,到处都能看到它的身影。但很多项目还停留在JSP+Servlet或者SpringBoot+Thymeleaf的旧模式,前后端耦合在一起,改个页面都要重启服务。今天要聊的这套源码用SpringBoot + Vue3 + MyBatis + MySQL的组合,后端只出接口,前端单独维护,整个架构从一开始就是按真实项目的标准来组织的。
如果你正打算做一个前后端分离的Java项目,或者想用一套完整源码来研究Vue3配合SpringBoot到底怎么配合,这篇文章可以帮你省下不少摸索的时间。我会从项目功能拆解、技术选型理由,到后端分层实现、前端封装联调,再到部署时踩过的坑,尽量把每个环节为什么这样做讲清楚。内容偏实操,跟着走一遍,你不仅能跑起这个图书管理系统,还能顺手把它改造成自己想要的形态。
1. 项目定位与功能拆解
1.1 图书管理系统到底要解决什么问题
做图书管理系统之前,先别急着打开IDE写代码。我习惯先想一个问题:图书管理员每天最烦什么?图书找不到、借出不知道借给谁、归还日期记不清、盘点数目对不上。系统要解决的其实就是这类琐碎问题——把纸面记录变成数据结构,把人工核对变成SQL查询。
所以这个项目围绕三条主线展开:图书档案管理、借阅流转管理、读者信息管理,再配上统计和登录权限。这三条线几乎覆盖了CRUD、多表联查、条件分页、状态流转、关联删除这些Java后端最常见的面试考点,这也是我推荐拿它练手的原因。
举个具体的例子,借阅模块不是简单的insert一条记录,它至少要处理三件事:查读者是否存在且未拉黑、查图书库存是否大于0、把图书表的库存字段减一。如果归还,还要反查借阅记录更新状态、库存加一。这套业务流转做下来,你对事务和SQL的掌握会比写一百个单表接口都扎实。
1.2 为什么必须前后端分离
很多教材项目至今仍用Thymeleaf直接把数据渲染到页面,一个Controller里既写接口又返回视图。以前的玩法看着简单,可一旦页面复杂起来就难受得要命——前端改一行样式,后端要重新编译打包;接口给第三方用,还得再复制一套JSON输出。
前后端分离的好处是后端只关心接口返回数据,前端只关心页面怎么渲染。开发时两边可以并行,前端用mock数据调样式,后端用Postman测接口,最后联调时对一下字段名就行。部署上也灵活,可以前端Nginx、后端jar包分开跑,也可以把前端打包产物直接扔进SpringBoot的static目录,一台服务器就能搞定。
这套源码采用的就是完全分离的方式,前端所有请求通过HTTP接口走后端,后端只返回JSON,不做任何页面跳转逻辑。在我看来,这才是一个能让你理解真实工作流的项目结构。
1.3 功能清单与页面规划
我用表格列一下这套图书管理系统最终包含的功能模块,方便你对照源码理解:
| 模块 | 页面功能 | 后端接口要点 |
|---|---|---|
| 登录模块 | 账号密码登录、退出、当前用户信息 | JWT Token签发与校验 |
| 图书管理 | 图书列表、条件搜索(书名/ISBN/分类/状态)、新增、编辑、删除、上架下架 | 分页查询、多条件动态SQL、库存更新 |
| 分类管理 | 分类树/列表、新增子分类、编辑、删除 | 父子级联、删除前检查关联图书 |
| 读者管理 | 读者列表、押金状态、借阅历史、冻结/解冻 | 关联借阅记录统计 |
| 借阅管理 | 借书登记、还书登记、借阅记录查询、超期标记 | 事务操作、库存联动、状态流转 |
| 统计面板 | 图书总量、借出数量、分类占比、近7日借阅趋势 | 聚合查询、日期分组 |
页面端对应的是Vue3里的路由划分,比如/book/list对应图书列表页,/borrow/record对应借阅记录页。路由守卫会在未登录时把用户踢回登录页。这套菜单和页面规划不算复杂,但每个页面都覆盖了“列表+表单+详情+删除确认”的基本闭环,是很典型的管理后台形态。
2. 技术栈选型与核心原理
2.1 SpringBoot:为什么它是后端的第一选择
SpringBoot真正解决了Spring配置地狱的问题。以前搭一个SpringMVC工程,要写一堆XML,配数据源、配事务、配扫描包,每一步都可能因为版本不兼容翻车。SpringBoot通过自动配置和约定优于配置,把大部分默认行为都给你安排好了,你只需要关注业务代码。
这套系统选用SpringBoot还有一个实际原因:部署成本低。打包成可执行jar后,服务器上只需要有JDK环境,一行java -jar就能启动,内置的Tomcat替你解决Web容器安装问题。对初学者来说,这是最容易获得正反馈的启动方式。
需要提醒的是SpringBoot版本选择。网上大量教程都在用2.x,但新项目用3.x也不少。版本太高容易遇到两类问题:一是javax包名变成了jakarta,老代码的import全部报错;二是部分第三方starter还没跟上,可能出现启动异常。如果你的毕设或生产项目追求稳定,建议先用SpringBoot 2.7.x搭配JDK8,把整套流程跑通后再考虑升3.x。我从实际经验出发,版本升级的坑远比功能收益多,没必要在这上面折腾。
2.2 MyBatis + MySQL:灵活SQL与配置细节
选MyBatis而不选JPA,核心原因就一条:SQL的可控性。图书管理系统的查询条件千奇百怪——书名模糊匹配、分类筛选、状态组合、日期区间,MyBatis可以让你直接写原生SQL,动态标签对条件进行拼接,完全清楚最终执行的是什么东西。调试起来也直观,把日志里的SQL复制到Navicat里一跑,问题出在哪一清二楚。
这套项目里,MyBatis的使用方式是经典的三件套:Mapper接口定义方法、XML文件写SQL、实体类做参数映射。动态SQL的<where>、<if>、<foreach>标签会在图书列表查询里大量出现,这也是为什么我说做这个项目能练到面试常问的MyBatis动态SQL。
MySQL这边要提醒几个细节。第一是字符集,建库时建议指定utf8mb4,它可以存emoji和生僻字,否则前端输入特殊字符可能报错。第二是时区,连接串里加上serverTimezone=Asia/Shanghai,不然日期字段会差几个小时。第三是SSL连接,如果你用的是MySQL 8.0以上,连接串不加useSSL=false,本地测试时经常报SSL错误,虽然不影响功能但看着很闹心。这三个配置我在后文会给出具体写法。
2.3 Vue3:组合式API带来的开发效率提升
Vue3最核心的变化是引入了Composition API,也就是setup语法。这个转变对管理后台类项目尤其友好。以前用Options API,一个页面的数据、计算属性、方法按选项散落各处,代码一旦超过300行,维护起来就东翻西找。Composition API允许你把某个业务逻辑的变量和函数写在一起,按模块组织代码,阅读体验和复用性都强得多。
我打个比方:Options API像你衣柜里所有衣服都按类型叠好,找一件T恤得翻半天;Composition API像按套装挂起来,一套衣服一个区域,拿起来就能穿。图书列表页如果把“搜索表单、表格数据、分页参数、加载方法”聚拢在一块,逻辑清晰程度会完全不一样。
Vue3配合Vite开发时,热更新速度比Webpack时代的Vue2快很多。再加上Element Plus组件库,表格、表单、弹窗、消息提示这些现成的UI组件基本能覆盖管理后台的全部需求。我实测跑下来,从初始化工程到做出第一个可联调的图书列表页,熟练的话半天就够。
3. 后端从0到1实现过程
3.1 数据库设计与建表SQL
数据库设计是整个项目的地基。我设计这套图书管理系统数据库时,遵循一个原则:先定业务关系,再定表结构。核心关系是图书和借阅记录是一对多,读者和借阅记录是一对多,分类和图书是一对多。基于这个关系,建表并不复杂。
我直接给出一份精简的建表SQL,方便你能在MySQL里跑通,实际源码会在字段上扩展一些时间字段和冗余字段。
CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4; USE library_db; CREATE TABLE `book_category` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL COMMENT '分类名称', `parent_id` int DEFAULT 0 COMMENT '父分类ID', `sort` int DEFAULT 0 COMMENT '排序号', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书分类表'; CREATE TABLE `book` ( `id` int NOT NULL AUTO_INCREMENT, `isbn` varchar(32) DEFAULT NULL COMMENT 'ISBN号', `name` varchar(128) NOT NULL COMMENT '书名', `author` varchar(64) DEFAULT NULL COMMENT '作者', `category_id` int DEFAULT NULL COMMENT '分类ID', `publisher` varchar(128) DEFAULT NULL COMMENT '出版社', `publish_date` date DEFAULT NULL COMMENT '出版日期', `stock` int DEFAULT 1 COMMENT '库存总量', `borrowed_count` int DEFAULT 0 COMMENT '当前借出数量', `status` tinyint DEFAULT 1 COMMENT '1在架 0下架', PRIMARY KEY (`id`), KEY `idx_category_id` (`category_id`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表'; CREATE TABLE `reader` ( `id` int NOT NULL AUTO_INCREMENT, `card_no` varchar(32) NOT NULL COMMENT '读者证号', `name` varchar(64) NOT NULL COMMENT '姓名', `phone` varchar(20) DEFAULT NULL, `status` tinyint DEFAULT 1 COMMENT '1正常 0冻结', PRIMARY KEY (`id`), UNIQUE KEY `uk_card_no` (`card_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='读者表'; CREATE TABLE `borrow_record` ( `id` int NOT NULL AUTO_INCREMENT, `book_id` int NOT NULL COMMENT '图书ID', `reader_id` int NOT NULL COMMENT '读者ID', `borrow_date` datetime NOT NULL COMMENT '借出时间', `due_date` datetime NOT NULL COMMENT '应还时间', `return_date` datetime DEFAULT NULL COMMENT '实际归还时间', `status` tinyint DEFAULT 0 COMMENT '0借阅中 1已归还 2超期', PRIMARY KEY (`id`), KEY `idx_reader_id` (`reader_id`), KEY `idx_book_id` (`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅记录表';库存字段我特意设计了stock和borrowed_count两个数。为什么不用一个remain_stock字段?因为图书列表页要展示总库存和借出数,如果用单字段,你得在列表接口里做子查询统计借阅表,性能差且写法复杂。冗余一个borrowed_count字段,虽然增加了一点维护成本,但换来了查询时的简单和高效。这种“用可接受的冗余换查询性能”的思路,在真实项目里非常常见。
3.2 工程初始化与分层配置
后端工程建议直接用Spring Initializr构建。依赖只需要spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-j、lombok这几个。如果是SpringBoot 2.x版本,MyBatis的starter坐标是org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.1;SpringBoot 3.x版本需要换2.3.x以上的starter,这块可以直接查Maven仓库最新版本。
整个工程我建议分成四个包:
controller:接收请求、参数校验、返回统一结果service:业务逻辑、事务管理mapper:MyBatis的Mapper接口entity:数据库表对应的实体类
再加上一个common包放统一返回体、异常处理、分页结果。分层的意义在于职责分离。Controller只做参数与响应的转换,Service只做业务规则,Mapper只做数据访问。你以后接手任何Java项目,基本都是这个套路,提前养成习惯会少吃很多亏。
application.yml里的关键配置我给出一份参考:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/library_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 你的密码 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个配置建议一定开启,它能自动把数据库的category_id映射成实体的categoryId,少写一堆resultMap。log-impl配置成StdOutImpl可以在控制台打印完整SQL,调试后端时能看清MyBatis实际执行了哪条语句、传了什么参数。我几乎每个项目默认都会开,联调阶段能省大量排查时间。
3.3 分层实现与核心代码示例
分层代码的关键就是每层别越界。我先从实体开始。图书实体类基本就是照着表字段写,用Lombok的@Data注解省掉getter/setter。
@Data public class Book { private Integer id; private String isbn; private String name; private String author; private Integer categoryId; private String publisher; private LocalDate publishDate; private Integer stock; private Integer borrowedCount; private Integer status; }Mapper接口只做接口声明:
@Mapper public interface BookMapper { List<Book> selectByCondition(@Param("name") String name, @Param("categoryId") Integer categoryId, @Param("status") Integer status, @Param("offset") int offset, @Param("limit") int limit); long countByCondition(@Param("name") String name, @Param("categoryId") Integer categoryId, @Param("status") Integer status); Book selectById(Integer id); int insert(Book book); int updateById(Book book); int deleteById(Integer id); }SQL写在XML里,重点看动态条件拼接:
<select id="selectByCondition" resultType="com.example.entity.Book"> SELECT * FROM book <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY id DESC LIMIT #{offset}, #{limit} </select><where>标签会自动去掉第一个多余的AND,这是MyBatis最常用的动态SQL技巧。分页我用手写LIMIT而不是引入PageHelper插件,理由是这套系统数据量不大,手写分页更容易理解原理,也能减少一个依赖。你要是在面试里被问MyBatis分页,先能解释清楚手写LIMIT,再说PageHelper的拦截器原理,层次会高很多。
Service层处理业务规则。拿借书接口举例:
@Transactional public void borrowBook(Integer bookId, Integer readerId) { Book book = bookMapper.selectById(bookId); if (book == null || book.getStatus() == 0) { throw new RuntimeException("图书不存在或已下架"); } if (book.getStock() - book.getBorrowedCount() <= 0) { throw new RuntimeException("图书库存不足"); } Reader reader = readerMapper.selectById(readerId); if (reader == null || reader.getStatus() == 0) { throw new RuntimeException("读者不存在或已被冻结"); } borrowRecordMapper.insert(bookId, readerId, LocalDateTime.now(), LocalDateTime.now().plusDays(30)); bookMapper.increaseBorrowedCount(bookId); }注意接口上的@Transactional注解。借书操作必须同时完成插入借阅记录和更新库存,两步不能分开执行。没有事务,中途抛异常就会出现“记录插入了但库存没扣”或者反过来,这类数据不一致在图书系统这种业务场景下很容易暴露。面试官很喜欢问的事务,这就是一个特别实在的落地点。
统一返回体我习惯这么设计:
@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("success"); 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; } }前端只要判断code是不是200,就能决定走成功逻辑还是错误提示。这套规范虽然简单,但前后端沟通成本会大幅度降低。
3.4 接口自测与调试技巧
写完接口不要急着写前端,先花半小时把接口全部验证一遍。我建议直接用Postman或Apifox,把每个接口的请求方式、参数、返回体确认好。这里有个小技巧:先按设计好的接口文档自测,再交给前端联调,表面上看多花了点时间,实际上能避免联调阶段反复拉扯。
调试MyBatis还有一个实用技巧:把log-impl配置好之后,每次请求都能在控制台看到类似这样的输出:
==> Preparing: SELECT * FROM book WHERE name LIKE CONCAT('%', ?, '%') AND category_id = ? LIMIT ?, ? ==> Parameters: 三(String), 1(Integer), 0(Integer), 10(Integer) <== Columns: id, isbn, name, author, category_id, stock, borrowed_count <== Row: 1, 9787111213826, 三体, 刘慈欣, 1, 10, 3如果你发现SQL里条件没拼上,或者参数传错了,控制台一对比就能看出来。很多新手遇到“列表查不出数据”的问题,第一反应是去前端调代码,其实更快的路径是先看后端控制台打印的SQL,拿到Navicat里执行一遍。哪个环节出问题,一目了然。
4. Vue3前端实现与关键细节
4.1 用Vite初始化工程与安装组件库
前端工程我推荐用Vite初始化,命令很简单:
npm create vite@latest library-web -- --template vue cd library-web npm install npm install element-plus axios vue-router pinia到这里有个常见的坑:很多人会用npm create vite交互式去选模板,跳到框架选择那一层容易选到React或原生JS。加--template vue直接指定模板,可以省掉选择题。我一般还会顺手把@vueuse/core装上,里面有一堆实用的组合式函数,比如useDebounceFn做搜索防抖,省得自己写定时器。
4.2 Axios封装与跨域处理
前端所有接口请求建议集中封装。一个基础配置长这样:
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) { return res.data } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default request这段代码有三个关键点:请求拦截器自动携带Token,响应拦截器统一处理业务码和401跳登录,接口调用方只需要关心成功数据。前端任何页面拿到的不再是包了一层的结果体,而直接是后端返回的业务数据,代码会干净很多。
关于跨域,本地开发时最容易遇到这个问题。Vite的解决方案是在vite.config.js里配置代理:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } } })这样前端请求/api/book/list会被代理转发到后端的/book/list,浏览器端不产生跨域请求,也就不会出现Access-Control-Allow-Origin的一堆报错。这里说句实在话,Vite的代理配置比Vue2时代的webpack还简单,但很多新手不知道有这个方案,愣是跑去后端加CORS配置,虽然也能解决,但本地代理才是前端团队更常用的玩法。
4.3 核心页面实现思路
图书列表页是整个系统最有代表性的页面。我建议用Composition API的组织方式,把搜索、表格、分页聚拢在同一个逻辑区域。我强烈建议用<script setup>语法,写法更简洁,导入组件后不需要注册,直接用就行。
用一个精简示例看看图书列表页的组织方式:
<template> <el-card> <el-form :inline="true" :model="queryForm"> <el-form-item label="书名"> <el-input v-model="queryForm.name" placeholder="请输入书名" clearable /> </el-form-item> <el-form-item label="分类"> <el-select v-model="queryForm.categoryId" placeholder="请选择分类" clearable> <el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="handleSearch">查询</el-button> <el-button @click="handleReset">重置</el-button> </el-form-item> </el-form> <el-table :data="tableData" border stripe v-loading="loading"> <el-table-column prop="isbn" label="ISBN" width="160" /> <el-table-column prop="name" label="书名" min-width="200" /> <el-table-column prop="author" label="作者" width="130" /> <el-table-column prop="stock" label="库存" width="80" /> <el-table-column prop="borrowedCount" label="借出" width="80" /> <el-table-column label="操作" width="160" fixed="right"> <template #default="{ row }"> <el-button link type="primary" @click="handleEdit(row)">编辑</el-button> <el-button link type="danger" @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="queryForm.pageNum" v-model:page-size="queryForm.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @change="loadData" /> </el-card> </template> <script setup> import { onMounted, reactive, ref } from 'vue' import { getBookList, deleteBook } from '@/api/book' const queryForm = reactive({ name: '', categoryId: null, pageNum: 1, pageSize: 10 }) const tableData = ref([]) const total = ref(0) const loading = ref(false) async function loadData() { loading.value = true try { const data = await getBookList(queryForm) tableData.value = data.list total.value = data.total } finally { loading.value = false } } function handleSearch() { queryForm.pageNum = 1 loadData() } function handleReset() { queryForm.name = '' queryForm.categoryId = null queryForm.pageNum = 1 loadData() } onMounted(loadData) </script>这里我特别解释一下loadData的写法。loading.value = true放在请求前,finally里关闭,无论接口成功还是失败都不会让表格一直处于加载状态。还有handleSearch把页码重置为1,这个细节很容易被忽略——如果你正在第3页筛选,不重置页码,查询结果出来可能是个空页,用户会误以为查询没生效。
4.4 路由守卫与权限控制
有了Token之后,前端还需要用路由守卫控制页面访问权限。在router/index.js里加一个全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else if (!token) { next('/login') } else { next() } })这段逻辑的作用是:未登录用户无论访问哪个页面,都会被强制跳回登录页。看起来简单,但它能挡住一大类问题——直接刷新页面时,Vue应用状态丢失,如果没有守卫判断,用户会看到一个空白页面或一堆接口报错。有了守卫,刷新后先验证本地Token,再决定放不放行。
我实测下来,一套图书管理系统的前端,最核心的就是这四块:工程初始化、请求封装、页面组件、路由守卫。把这四块吃透,你几乎可以拿着同一套模板去套任何管理后台项目。
5. 联调部署与问题排查实录
5.1 前后端联调中的高频问题
联调阶段是项目中最容易出问题的地方。我做个速查表,这些问题基本属于必踩类型:
| 现象 | 根本原因 | 解决思路 |
|---|---|---|
| 前端请求报CORS错误 | 端口不同导致的跨域 | 优先用Vite代理,生产用Nginx反向代理 |
| 时间精确到秒但前端只显示日期 | 后端返回格式不匹配 | 统一在实体字段加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| 删除图书报无法删除 | 借阅记录关联数据未处理 | 先检查借阅表中是否有未归还记录,提示用户 |
| 列表接口返回字段全是null | 实体属性与表字段映射不上 | 开启map-underscore-to-camel-case或补充resultMap |
| 分页后总条数对不上 | 没走count查询或一对多关联查询 | 将count和list分开查询 |
| 中文乱码 | 数据库字符集或连接参数问题 | 统一utf8mb4,连接串加characterEncoding=utf8 |
CORS问题我再多说几句。前端项目如果用npm run dev起在5173端口,后端8080端口,直接请求后端接口,浏览器会因为跨域拦截报错。最省事的做法是从一开始就约定好,前端所有请求都走/api前缀,开发时Vite代理转发,部署时Nginx反向代理到后端端口。这样代码里不需要写任何跨域注解,生产环境也不会暴露后端端口,安全和维护性都会好很多。
5.2 打包部署的两种路径
联调完成后,接下来就是部署。这套系统有两种部署方式都可以选。
方式一:前端打包后交给SpringBoot托管。先在前端目录执行npm run build,会产生一个dist目录。把这个目录里的文件全部复制到SpringBoot项目的src/main/resources/static目录下,重新打包后端jar。这样前端页面和后端接口都跑在同一个端口,访问根路径就是图书管理系统页面。这种方式适合小项目,一台服务器、一个进程,部署最简单。
方式二:前端打包后用Nginx托管,后端jar单独跑。Nginx配置静态资源目录和反向代理:
server { listen 80; server_name your-domain.com; location / { root /var/www/library-web/dist; index index.html; 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这个配置尤其重要。Vue3是单页应用,路由用的是history模式,用户刷新/book/list页面时,Nginx如果没有这个配置,会直接返回404。加上try_files后,所有路径都会回退到index.html,由前端路由接管。这个坑我见过太多次了,不算复杂,但不加上页面刷新就挂。
方式二适合后续要扩展、做负载均衡、前后端要分别部署的场景。个人项目我建议先用方式一,一步到位部署最简单。
5.3 一些实际的避坑经验
最后分享几个我在这个项目上积累的实操经验,基本都是百度不到的那种。
第一,SQL date类型别用java.util.Date。Java 8之后建议用LocalDate和LocalDateTime,和MySQL的date/datetime对应更准确,也省去时区转换的麻烦。如果项目里日期字段出现“差8小时”的诡异情况,多半就是Date和时区在捣乱。
第二,前端删除操作一定要二次确认。Element Plus的ElMessageBox.confirm不算复杂,但很关键。很多用户误删图书,如果连个确认弹窗都没有,体验非常糟糕。从设计角度说,删除和新增编辑不同,它是不可逆操作,必须给用户一个反悔的机会。
第三,图书库存扣减要考虑并发。借书接口虽然加了事务,但高并发场景下两个用户同时借同一本书,理论上是可能超借的。这个项目里我用UPDATE book SET borrowed_count = borrowed_count + 1 WHERE id = ? AND borrowed_count < stock这样的条件更新来解决,让数据库帮我们做校验。你面试时能提到这种细节,比背八股文加分得多。
第四,MyBatis的缓存机制。默认情况下MyBatis的二级缓存是关闭的,你不需要刻意配置。网上很多文章把它讲得神乎其神,实际项目里我反而不建议乱开,尤其是这种图书借阅系统,数据变动频繁,缓存一旦失效策略没配置好,很容易出现脏数据。保持默认关闭,等真的需要性能优化时再做针对性的缓存设计。
6. 这套源码还能怎么扩展
如果你跑通这套图书管理系统之后觉得不过瘾,我建议从这几个方向做升级:用Redis缓存图书分类和热门图书排行榜,减少数据库压力;给借阅模块加定时任务,每天自动扫描超期记录并更新状态;用Excel导出功能,把图书清单和借阅记录导给管理员归档;加一个简单的读者OpenID登录,变成手机端也能用的形态。
我个人做这个项目最大的体会是,一个系统只有跑在真实场景里才会暴露各种问题。你设计得再完美的表结构,遇到一个“同一本书被两个人同时借”的需求,也得去思考并发控制;你接口文档写得再清楚,遇到前端传错一个字段名,也会去追数据流。这些经验不是看书看出来的,是要动手一个字一个字敲出来才能沉淀下来的。
对我来说,图书管理系统最大的价值不在于功能本身,而在于它把Java后端、Vue3前端、MySQL存储这条完整链路串到一起,让你在做一个“正经系统”的过程中,把那些零散的知识点真正焊接起来。你可以把它当作面试前的实战训练场,也可以在这个基础上加自己的业务需求。拿这套代码多折腾几次,收获一定会超出预期。