做图书管理系统的源码很多,但大部分都是“能跑通的demo”:后端打个CRUD接口,前端画几个表格,录一本加一本,顶多再加个模糊搜索,然后就在简历上写“完成图书管理系统开发”。但真要放到图书大厦这种场景里,业务链条远不止“增删改查”四板斧——多楼层馆区、不同身份的读者、借还高峰期的并发扣库存、逾期罚金结算、营业员的操作审计,每一块都可能把系统撕开一个口子。
这篇博文要聊的就是一套完整的SpringBoot+Vue+MyBatis+MySQL企业级图书大厦图书管理系统。它不是某个模块的片段代码,而是从需求拆解、数据库建模、后端事务与并发控制、前端借阅工作台,到部署上线的全链路实现。文章会把关键设计决策的“为什么”讲清楚,也会把我在实际开发中踩过的坑直接摆出来,适合正在做毕业设计、接外包,或者想搞明白一套真实业务系统怎么从零落地的同学参考。
1. 为什么图书大厦的系统不能照着学校图书馆demo抄
1.1 图书大厦与普通图书馆的业务差异
很多人一听“图书管理系统”,第一反应就是学校图书馆期末作业那个量级:一个管理员账号,一个图书表,一个借阅表,完事。但图书大厦这类场所,业务模型要复杂得多。
首先是物理空间的维度。图书大厦通常按楼层和馆区分区,一层可能是社科畅销区,二层是儿童绘本区,三层是专业资料库。图书表里必须有对应的馆藏位置字段,不然读者查到书却不知道去哪层拿,营业员上架也找不到货架。
其次是角色和权限的维度。除了系统管理员,还有营业员(负责借还、上下架)、读者(查询、预约、查看自己的借阅记录)。如果系统没有独立的角色权限体系,任何一个人登进来都能删库存、改价格,这在真实营业场景里是灾难。
第三是运营和合规的维度。借出去的书记录要能追溯,谁经手的、什么时候借的、该什么时候还;逾期了还要能自动算罚金;每天的借还量、热门书目排行这些统计报表,运营团队要拿来做决策。学校demo里那两张表根本支撑不了这些查询。
这也是我在接到这个项目需求时,第一件事不是写代码,而是把“管理员、营业员、读者”三类角色的完整业务流程画出来的原因。很多做崩的系统,崩在最开始的需求边界没理顺。
1.2 技术选型逻辑:SpringBoot+Vue+MyBatis+MySQL的取舍
标题定了这套技术栈,我来复盘一下这套组合在企业级项目里的合理性。
后端用SpringBoot:这是目前Java后端最主流的快速开发框架,自带的自动配置和内嵌容器大大降低了部署成本,生态里不管是安全框架、ORM还是缓存都能无缝整合。图书管理系统属于典型的中小型业务系统,用SpringBoot单体应用足够,没必要上微服务——微服务的拆分、服务发现、分布式事务在这里只会徒增复杂度。
持久层用MyBatis:有人会问,为什么不用JPA?MyBatis对SQL有完全的控制力,复杂查询(比如图书检索里的多条件动态拼接、报表统计里的聚合SQL)写起来直接、直观,调优也方便。图书大厦系统的查询条件组合非常多——书名、ISBN、分类、楼层、库存状态——MyBatis的动态SQL做这类需求非常顺手。JPA在简单CRUD上确实省事,但一旦查询复杂起来,拼Specification或者写JPQL,远不如直接SQL来得痛快。
数据库用MySQL:企业级场景下MySQL 8.x的成熟度、稳定性、运维成本、社区资料都是经过大规模验证的。表结构合理设计、索引到位之后,支撑几万册图书和几十万条借阅记录的日常业务完全没有压力。不用PostgreSQL或者Oracle,不是它们不好,而是在这个体量下MySQL是性价比最高的选择,团队招人也最容易。
前端用Vue:Vue的响应式数据绑定和组件化开发,做后台管理系统非常高效。配合Element Plus这类现成的组件库,表格、表单、弹窗、分页这些后台高频交互能快速落地,而且Vue的上手成本低,前后端联调时沟通效率也更高。
这套组合从开发效率、可控性和运维成本三个角度看,对一个“企业级图书大厦”项目来说,是务实的选择。
2. 数据模型设计:一张表把“大厦”拆成可落地的结构
2.1 核心表结构与字段说明
数据模型是整个系统最需要提前花时间的部分。表设计好了,后面所有业务代码都是顺着这个骨架走。我这里把核心几张表的要点列出来。
用户表 t_user
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| username | varchar(50) | 登录名,唯一索引 |
| password | varchar(100) | BCrypt加密后的密文 |
| real_name | varchar(50) | 真实姓名 |
| role | varchar(20) | ADMIN / OPERATOR / READER |
| phone | varchar(20) | 手机号 |
| status | tinyint | 1启用,0禁用 |
| create_time | datetime | 创建时间 |
图书表 t_book
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| isbn | varchar(20) | ISBN编号,索引 |
| book_name | varchar(200) | 书名 |
| author | varchar(100) | 作者 |
| publisher | varchar(100) | 出版社 |
| category_id | bigint | 分类ID,关联t_category |
| price | decimal(10,2) | 定价 |
| floor_no | varchar(20) | 所在楼层 |
| shelf_no | varchar(50) | 货架编号 |
| total_stock | int | 总库存 |
| available_stock | int | 可借库存 |
| status | tinyint | 1上架,0下架 |
| create_time | datetime | 录入时间 |
借阅记录表 t_borrow_record
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 借阅人ID |
| book_id | bigint | 图书ID |
| borrow_time | datetime | 借出时间 |
| due_time | datetime | 应还时间 |
| return_time | datetime | 实际归还时间,可空 |
| operator_id | bigint | 经办营业员ID |
| status | tinyint | 1借出,2已还,3逾期未还 |
| fine_amount | decimal(10,2) | 罚金金额 |
分类表 t_category:id、name、parent_id,支持二级分类。比如“文学”下面还可以拆“小说”“散文”,图书大厦的图书分类比学校图书馆细很多,没有层级结构会很难维护。
操作日志表 t_operation_log:id、user_id、action、target_type、target_id、detail、create_time。审计追溯就靠它。
这里要强调一个细节:表的字段命名我全程用下划线风格,配合MyBatis的map-underscore-to-camel-case配置,实体里直接用驼峰字段,少写很多映射注解。
2.2 索引、唯一约束和软删除的设计
表建好只是第一步,索引设计直接决定系统在高数据量下的查询性能。
我在实际设计里做了这几件关键的事:
第一,借阅记录表建联合索引。日常查询基本都是围绕某个读者查“他借了什么书”,或者围绕某本书查“这本书被谁借走了”。所以(user_id, status)和(book_id, status)这两个联合索引是必须的。如果不建,几十万条记录里按照用户ID过滤就会走全表扫描,接口响应直接从毫秒级变成秒级。
第二,ISBN做唯一约束。同一本书的ISBN应该是唯一标识,如果允许重复录入,图书检索和统计就会出现脏数据。唯一约束在数据库层面兜底,比在业务代码里先查再插可靠得多,因为业务代码的判断在并发下可能有空隙。
第三,所有核心业务表保留status字段做软删除。图书大厦的管理人员误删一本在架图书,如果物理删除了,所有历史借阅记录的外键就悬空了。用status字段标记删除状态,业务查询默认过滤status = 1,既能防止误删,又能保留完整数据链条。
2.3 初始化数据与演示账号
源码里我准备了一份初始化SQL,除了建表语句,还内置了几组演示账号和一批示例图书数据。
演示账号设计成三种角色:
- 系统管理员 admin / 123456
- 营业员 operator01 / 123456
- 读者 reader01 / 123456
图书数据我按照图书大厦常见的分区风格放了百来本,覆盖小说、儿童、经管、科技几个大类,分布在不同楼层货架。这样做的价值在于:前端页面联调时,登录进去就能看到有数据的界面,不会对着空表干瞪眼;运营人员验收系统时,也能直观地看到统计图表是有数据的。
提示:初始化SQL里的用户密码全部是BCrypt加密后的值,不能直接在数据库里改成明文。我后面会单独讲密码加密的处理方式。
3. 后端搭建:SpringBoot整合MyBatis的完整实践
3.1 项目分层与包结构
后端工程我按标准的分层架构组织,包结构如下:
com.library ├── config // 配置类:安全配置、跨域、异常处理 ├── controller // 接口层 ├── service // 业务层,接口和实现分离 ├── mapper // MyBatis的Mapper接口 ├── entity // 数据库实体 ├── dto // 请求/响应对象 ├── common // 公共类:统一返回结果、常量、异常 └── util // 工具类:JWT工具等分层的好处不用多说,但我要特别强调一个容易被忽略的原则:Controller层只做参数接收和结果包装,Service层只做业务逻辑,Mapper层只做数据访问。网上很多demo把业务判断写在Controller里,一开始看着代码少,实际上业务一复杂就全乱套了,改一个借书规则要翻遍好几个接口。
实体和DTO我分开写。数据库实体是entity,前端请求参数和返回结果用dto。这样做看似多写几个类,但避免了把密码等敏感字段暴露给前端,也方便根据页面需求组合返回字段。
3.2 关键配置与依赖
pom.xml里核心依赖是这些:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>MySQL 8.x的驱动类名和连接方式跟5.x有区别,配置的时候要特别注意:
spring: datasource: url: jdbc:mysql://localhost:3306/library_tower?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.library.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里面有两个我印象很深的坑,单独说一下。
第一个坑是serverTimezone参数。如果不加serverTimezone=Asia/Shanghai,MySQL 8连接时会直接报时区错误,或者返回的时间跟本地时间差8个小时。这个参数在本地开发时往往被忽略,部署到云服务器后一查数据全是乱的。
第二个坑是mapper-locations的路径。如果XML文件没放到resources/mapper/对应的位置,启动时会报Invalid bound statement(not found)。我见过很多同学把XML放在Java目录下,IDE里看着好好的,打包成jar之后就找不到文件了。XML和Mapper接口一定要按配置的路径放好。
3.3 动态SQL实现多条件图书检索
图书检索是读者端使用频率最高的功能,它的特点就是查询条件不固定:用户可能只输入书名,可能只选择分类,可能只按楼层过滤,也可能几个条件一起上。
MyBatis的动态SQL在这里是刚需:
<select id="searchBooks" resultType="com.library.entity.Book"> SELECT * FROM t_book <where> <if test="bookName != null and bookName != ''"> AND book_name LIKE CONCAT('%', #{bookName}, '%') </if> <if test="isbn != null and isbn != ''"> AND isbn = #{isbn} </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="floorNo != null and floorNo != ''"> AND floor_no = #{floorNo} </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select><where>标签会自动去掉第一个条件前面的AND,这样就不用手动拼“WHERE 1=1”这种丑代码。查询参数用#{param}而不是${param},是防止SQL注入的关键,这是MyBatis的预编译机制,任何时候都不能图省事改成${}。
分页我把offset和pageSize直接作为参数传入,简单可控。也可以引入PageHelper插件,不过这个项目体量下,手写LIMIT分页已经足够清晰。统计总条数的时候单独写一个countBooks查询,配合前端表格的分页展示。
4. 借还书这个核心链路:事务、并发与罚金计算
4.1 借书流程的接口设计与事务边界
图书管理系统里最核心的业务是借书和还书,这个链路的正确性直接决定系统能不能用。借书接口的关键不是“插入一条记录”这么简单,它要保证一系列动作的原子性。
我梳理的借书流程是:
- 校验读者身份和账号状态(禁用状态不能借书)
- 校验图书状态(下架的不能借)
- 扣减可借库存
- 生成借阅记录,设置应还时间(系统默认30天,可按会员等级调整)
- 记录操作日志
这五个步骤任何一步失败,前面的操作都要回滚。比如库存扣了但借阅记录没生成成功,书就“凭空消失”了。所以整个流程在一个事务方法里处理:
@Transactional(rollbackFor = Exception.class) public BorrowResult borrowBook(Long userId, Long bookId, Long operatorId) { User user = userMapper.selectById(userId); if (user == null || user.getStatus() != 1) { throw new BusinessException("读者不存在或账号已锁定"); } Book book = bookMapper.selectByIdForUpdate(bookId); if (book == null || book.getStatus() != 1) { throw new BusinessException("图书不存在或已下架"); } if (book.getAvailableStock() <= 0) { throw new BusinessException("库存不足"); } int updated = bookMapper.decreaseStock(bookId); if (updated == 0) { throw new BusinessException("库存不足"); } BorrowRecord record = new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setBorrowTime(new Date()); record.setDueTime(DateUtil.addDays(new Date(), 30)); record.setOperatorId(operatorId); record.setStatus(BorrowStatus.BORROWED.getValue()); borrowRecordMapper.insert(record); return new BorrowResult(record); }事务注解一定要加rollbackFor = Exception.class。这是因为Spring默认只对RuntimeException回滚,如果业务方法抛出了受检异常,不加这个参数事务不会回滚,数据就会出现部分更新。
4.2 库存扣减的并发控制
借书场景最典型的并发问题是:两个读者同时借同一本只剩最后库存的书,如果两个请求同时读到available_stock等于1,都判断“库存足够”,然后都去减库存,最终就会出现超借。
解决并发扣减我采用的是数据库行锁的方案:
<select id="selectByIdForUpdate" resultType="com.library.entity.Book"> SELECT * FROM t_book WHERE id = #{id} FOR UPDATE </select>SELECT ... FOR UPDATE会对命中的行加排他锁,第二个事务在第一个事务提交之前只能等待。这样“查库存-判断-扣减”就变成了串行操作,从根本上避免了超借问题。
这个方案用起来有两个注意点。
第一,必须在事务里使用。行锁在事务提交时才会释放,如果查询方法外面没有事务包裹,锁会立即释放,加了等于白加。
第二,锁的范围要小而准。这里锁的是一本具体图书的行,不是整张表,并发时其他图书的借阅完全不受影响,性能损耗是很低的。相比之下,用synchronized或者分布式锁虽然也能解决,但在单机事务场景下杀鸡用牛刀,而且分布式锁还要考虑锁超时和释放问题。
还有一种做法是乐观锁:update时带WHERE available_stock > 0条件,通过受影响行数判断是否抢到库存。这个方案也能用,但行锁在并发量不极端的情况下更直观、更好理解,我就选了它。
4.3 还书、逾期与罚金逻辑
还书流程和借书相反,核心动作是更新借阅记录状态、回补库存,同时判断是否逾期并计算罚金。
@Transactional(rollbackFor = Exception.class) public ReturnResult returnBook(Long recordId, Long operatorId) { BorrowRecord record = borrowRecordMapper.selectByIdForUpdate(recordId); if (record == null || record.getStatus() != BorrowStatus.BORROWED.getValue()) { throw new BusinessException("借阅记录不存在或已归还"); } Date now = new Date(); record.setReturnTime(now); record.setStatus(BorrowStatus.RETURNED.getValue()); record.setOperatorId(operatorId); if (now.after(record.getDueTime())) { long overdueDays = DateUtil.betweenDay(record.getDueTime(), now, true); BigDecimal fine = BigDecimal.valueOf(overdueDays) .multiply(Constants.FINE_PER_DAY); record.setFineAmount(fine); } else { record.setFineAmount(BigDecimal.ZERO); } borrowRecordMapper.updateById(record); bookMapper.increaseStock(record.getBookId()); return new ReturnResult(record); }罚金规则我定义为每本每天0.5元,这个费率在配置常量里管理,方便运营调整。逾期天数按Math.ceil向上取整,比如超期1小时也算逾期1天,这是行业里常见的做法,避免因为精确到小时产生争议。
这里有个容易忽略的细节:还书回补库存之前,也要先锁定借阅记录。原因是防止两个营业员同时使用同一个借阅单号操作,或者同一本书两次归还导致库存多回补。锁住记录行之后,第二次操作就能通过状态校验拦截掉。
5. Vue前端:面向营业员的借阅工作台怎么做
5.1 前端工程结构与登录流程
前端我采用的是Vue 3 + Element Plus + Vue Router + Pinia + Axios的组合。工程结构按照后台管理系统最常见的模式组织:
src ├── api // 接口请求封装 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── store // Pinia状态 ├── utils // 工具:axios实例、token存取 └── views // 页面 ├── login // 登录页 ├── dashboard // 首页仪表盘 ├── book // 图书管理相关 ├── borrow // 借还管理 ├── reader // 读者管理 └── system // 系统管理登录流程是前后端交互的第一个关键点。用户在登录页输入账号密码,后端校验成功后返回JWT令牌。前端拿到token后存储在localStorage里,同时把用户基本信息存到Pinia里,后续所有请求自动带上token头部,路由守卫检查token存在才能进入业务页面。
在登录这一块,我建议后端接口返回的数据要区分token和userInfo两部分:token用来做身份凭证,userInfo用来控制前端页面显示哪些菜单和按钮。如果把角色信息只存在token里,前端每次都要解析JWT,很别扭。
5.2 axios封装、Token持久化与跨域处理
axios的封装是整个前端工程质量的分水岭。我习惯建一个统一实例,把公共逻辑全收拢进去:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器:自动携带token service.interceptors.request.use( config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }, error => Promise.reject(error) ) // 响应拦截器:统一处理业务错误和登录过期 service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default service有一个前后端分离项目的经典问题:跨域。我的做法是让前端通过baseURL: '/api'发起请求,然后在开发环境通过Vite代理转发到后端,生产环境通过nginx把/api前缀的请求反向代理到后端服务。这样浏览器看到的始终是同源请求,不需要后端开CORS,也避免了很多跨域安全的坑。
Vite的开发代理配置:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }5.3 图书借还页面与扫码交互设计
营业员日常工作最密集的场景就是借书和还书,这个页面我花了很多心思。
借书操作的设计逻辑是:营业员输入或扫入读者编号,系统回显读者信息;再输入或扫入图书ISBN,系统查询这本书并显示库存和馆藏位置;确认无误后点击借出按钮。
这里做了一个交互优化:输入框支持连扫和连续输入。营业员操作时经常是一手拿着扫码枪,一手操作键盘,所以读者编号和图书ISBN的输入框要支持扫码枪的快速输入模式,扫完一个立即清空并自动聚焦到下一个输入框。Vue里用ref控制焦点就能轻松实现。
还书页面则简单直接:扫描图书条码后,自动匹配当前借出状态的借阅记录,展示读者信息和借阅天数、是否逾期、罚金金额,营业员确认后点击归还。
这个页面还有一个细节值得提:馆藏位置提示。图书大厦楼层多,营业员根据提示能快速定位书架,所以页面在显示图书信息时,把floor_no和shelf_no放在显眼位置,甚至可以做成卡片样式,方便肉眼快速扫到。
6. 企业级细节落地:权限、日志、性能和安全性
6.1 RBAC权限模型在系统里的实现方式
权限控制我采用的是经典RBAC模型。虽然系统只有三种角色,但我不建议直接把角色写死在业务判断里,而是通过角色+接口的映射做统一控制。
后端通过Spring Security实现:
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests() .requestMatchers("/api/auth/login", "/api/book/search").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/operator/**").hasAnyRole("ADMIN", "OPERATOR") .requestMatchers("/api/reader/**").authenticated() .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }规则是:登录接口和图书检索接口所有人可访问;管理员相关的接口只有ADMIN能访问;营业员操作的接口ADMIN和OPERATOR都可以;读者的个人接口只要登录即可。
前端路由也做了一层配合,根据角色动态注册可访问的路由和菜单。这样双端控制虽然会增加一点工作量,但能避免“接口改了、前端还能进”这种权限漏洞。
6.2 操作审计日志与借阅历史
企业级系统必须有审计能力。营业员在系统里做的每一次敏感操作——上架图书、下架图书、办理借书、办理还书、调整罚金——都要记录谁在什么时间做了什么操作。
我用Spring AOP实现了一个统一的操作日志切面,在需要审计的方法上加自定义注解,切面自动拦截并记录:
@Aspect @Component public class OperationLogAspect { @Around("@annotation(operationLog)") public Object record(ProceedingJoinPoint point, OperationLog operationLog) throws Throwable { Object result = point.proceed(); // 在正常执行后记录日志 // 获取当前登录用户、方法名、参数、耗时 return result; } }这里我刻意选择在方法正常返回后记录日志,而不是在方法执行前记录。因为操作失败时的日志价值没那么高,而且一个真正成功的操作才需要审计。
借阅历史的逻辑则要区分两个视角:读者端看到的是“我借了哪些书、什么时候还”;运营端看到的是“某本书的流通记录、某读者的借阅轨迹”。两张报表本质上都查t_borrow_record,只是过滤维度和展示字段不同,可以共用一套查询接口,前端传不同参数区分。
6.3 性能优化:分页、缓存和连接池
业务量起来之后,性能瓶颈通常出现在几个地方,我提前做了布局。
列表查询全分页。无论是图书列表、借阅记录还是操作日志,都采用分页查询。图书数据可能上万条,一次性全查出来用户也看不过来,还白白占用网络和内存。
热门数据用缓存。图书分类这种变更频率极低、读取频率极高的数据,用@Cacheable做了一层本地缓存,避免每次查询分类列表都打数据库。图书检索结果不做缓存,因为库存数据实时性要求高,缓存的收益太低。
数据库连接池重视配置。默认的连接池参数在并发稍高时会成为瓶颈。我这里给了HikariCP一个合理的初始连接数和最大连接数:
spring: datasource: hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000最大连接数20对于图书大厦这种规模的系统足够,但又不会因为连接数过多拖垮数据库。这个参数生产环境一定要监控实际连接数再调整,不能拍脑袋。
6.4 安全注意点:密码加密、SQL注入防护、长整型精度
安全相关的几个点,很多项目会忽视,但出了事都是大问题。
密码必须加盐加密。登录密码存储用的是BCrypt,它能自动生成随机盐,即使两个用户的密码相同,加密后的密文也不同。登录校验时用matches方法比对。我不会把密码明文登在数据库,也不会用MD5这种已不安全的算法。
SQL注入的防线在预编译。MyBatis的#{}就是预编译占位符,能避免拼接SQL导致的注入风险。我明确要求项目里所有查询都用#{},杜绝${}拼表名或列名。
Long类型主键的JSON精度问题。Java的Long类型如果超过JavaScript的Number安全整数范围(2的53次方减1),前端拿到的ID精度会丢失。数据库自增主键在数据量大时很容易超过这个范围。解决办法是在返回前端的DTO里给ID字段加上ToString序列化注解:
@JsonSerialize(using = ToStringSerializer.class) private Long id;这样前端拿到的就是字符串,主键精度不会丢,后面做更新、删除操作时传给后端也能准确匹配。
7. 从源码到上线:部署步骤与我踩过的坑
7.1 本地跑通全流程
拿到源码第一件事,是让系统在本地完整跑起来。我按从零开始的环境准备顺序,列一份完整操作清单。
- 安装MySQL 8.x,执行
sql/init.sql初始化数据库,包含建库、建表、初始数据。 - 修改
application.yml中的数据库账号密码,确保能连上本地MySQL。 - 启动后端:
mvn spring-boot:run,看到“Started Application”表示启动成功。 - 安装Node.js 16以上版本,进入前端目录执行
npm install安装依赖。 - 修改前端
.env.development中的接口地址配置,执行npm run dev启动开发服务器。 - 浏览器访问
http://localhost:5173,用演示账号登录。
我在源码里把前后端默认端口都固定好了:后端8080,前端Vite默认5173。如果本地端口被占用,改配置即可,但要注意同步修改前端的代理转发目标和后端的允许来源。
7.2 服务器部署与nginx反向代理
生产环境部署,我的经典组合是后端jar包 + nginx托管前端静态文件。
后端打包:
mvn clean package -DskipTests java -jar library-system-1.0.0.jar后端用nohup后台运行并输出日志到指定文件,方便排查问题:
nohup java -jar library-system-1.0.0.jar > /app/logs/system.log 2>&1 &前端构建:
npm run build # 将生成的dist目录上传到服务器nginx配置核心部分:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }try_files这行是Vue Router的History模式必须的:前端路由的路径在nginx里找不到对应文件时,回退到index.html由前端路由接管,否则刷新页面就会404。
7.3 几个高频问题的排查记录
最后分享几个我在开发和运维这套系统过程中实际遇到的问题,每个都花过不少时间定位。
问题一:修改用户密码后无法登录
原因通常是数据库里的密文和登录时传入的明文密码不匹配。排查思路:先确认数据库里存的密码字段是不是BCrypt格式,调试时直接在登录接口打日志,看用户查出来的密文和传入的明文能否通过matches校验。如果期间有人在数据库里手工UPDATE过密码为明文,BCrypt校验必然失败。
问题二:前端页面总是白屏
Vue项目构建后部署,出现白屏先看浏览器控制台报错。最常见的是静态资源路径问题。如果部署在域名根路径下,Vite的base配置是根路径;如果部署在子目录,就要配置base: '/subpath/',否则CSS和JS文件引用路径不对。
问题三:借阅高峰期接口响应变慢
现象是下班前借还高峰期,借书接口从几十毫秒变成几秒。我先看数据库慢查询日志,发现借阅记录表的数据量已经比较大,而部分查询没有走索引。后来补上了(user_id, status)和(book_id, status)两个联合索引,问题直接解决。这提醒我:上线前的表设计要预留索引,上线后要监控慢查询。
问题四:事务不生效导致库存异常
这是典型的Spring事务陷阱。我在同一个Service里写了A方法调用B方法,B方法上标注了@Transactional,但通过this调用时注解失效,因为事务是通过代理对象生效的,直接调用内部方法绕过了代理。解决方式是把被调用方法放到另一个Service里注入调用,或者自行注入代理对象。
这套图书大厦管理系统做到最后,我最深的体会是:技术上没有哪个模块是“高不可攀”的,真正的难点全在业务边界、并发细节和数据一致性这些看不见的地方。如果你正在照着源码学习改造,建议不要急着通读所有代码,先跑起来,然后挑一个你最关心的业务场景(比如借还书那个事务)走一遍完整链路,再对照这篇文章看设计意图,收获会大得多。源码里我保留了完整的注释和初始化数据,遇到跑不起来的问题,先查数据库连接和前端代理这两处,大部分情况都是环境配置的锅。