去年我接手了一个企业内部的闲置图书分享系统,需求本身并不复杂——员工把不再看的书登记进系统,其他人可以检索、借阅、归还,就这么一个闭环。但“企业级”这三个字意味着东西不能只是跑起来,权限要分级、数据要有审计、异常要能兜底、代码要能交得出手。我最终选择用 SpringBoot + Vue + MyBatis + MySQL 这套组合来落地,也就是标题里提的那个“bootpf”项目。整套源码做完之后,我把它整理成了完整版分享出来,这篇就把我整个设计和踩坑过程拆开讲一讲,给想在类似场景下手做一套前后端分离系统的朋友一个参考。
这套系统适合谁?两类人。一类是刚学完 SpringBoot 和 Vue 基础、想找一个完整项目练手的学生或转行开发者,另一类是公司内部确实有图书借阅、工具借用这类资源分享需求、想低成本搭建一个内部平台的运维或开发同学。前者能从这里学到一套规范化的工程结构、权限设计和多表查询的实际写法,后者可以直接拿去改改就用。我下面讲的每个环节都基于实际代码和真实运行环境的验证,不跟你画饼。
1. 项目概述与设计思路拆解
1.1 核心需求解析与业务边界
先把需求掰碎了看。企业级闲置图书分享,本质上是“共享资源”的管理系统,核心玩家有三个:普通员工、图书管理员、系统管理员。普通员工要能注册登录、浏览图书、发起借阅、提交还书、查看个人借阅历史;管理员要能审核借阅请求、管理图书上下架、处理图书损坏或丢失;系统管理员则要管用户、管权限、看统计报表。
很多第一次做这类系统的人容易犯一个错误——上来就撸代码,连角色都没有定义清楚。结果做到后面发现普通用户能删图书、管理员不能审批,全乱了。我设计的第一件事是画出角色-权限矩阵,明确“谁能做什么”。这个矩阵不仅是后端接口权限控制的直接依据,也是前端菜单动态渲染的数据来源。
另一个容易忽略的点是“闲置”这个词的业务含义。闲置意味着图书不是随时都有的,同一本书可能只有一册,被人借走了就显示“不可借”。这就需要系统区分“图书信息”和“图书实例”:图书信息是《三体》这本书的元数据,图书实例是公司里实际存在的那一册。这个设计决定了后面数据库表怎么建、借阅流程怎么写,务必一开始就想清楚。
1.2 为什么选择前后端分离 + SpringBoot + Vue
选技术栈的时候我思考过几个方案:传统的 JSP + SpringMVC、单体 SpringBoot + Thymeleaf、前后端完全分离的 SpringBoot + Vue。最终选了后者,原因是这套系统的前端交互复杂度决定了它需要一套完整的前端工程体系。
图书列表要做分类筛选、模糊搜索、分页;借阅审核要做列表状态流转;后台管理要做仪表盘和图表统计。这些用 JSP 服务端渲染来做,前端的交互反馈会非常痛苦,每点一次筛选都要刷新页面。Vue 的响应式数据绑定和组件化开发天然适合这种中后台管理系统,vue-router 管页面跳转、vuex 管全局状态、axios 管接口请求,整个前端工程清晰可控。
后端用 SpringBoot 看中的是它解决了 Spring 框架“配置地狱”的问题。传统 SSM(Spring + SpringMVC + MyBatis)时代,光是一个 applicationContext.xml 就要配置一堆 bean,而且不同版本之间依赖兼容性问题多到让人崩溃。SpringBoot 的自动配置机制把这些东西全部隐藏了,我只需要在 application.yml 里写清楚数据源、MyBatis 的 mapper 扫描路径、端口号,就能把项目跑起来。同时 SpringBoot 内置 Tomcat,打包成 jar 直接 java -jar 就能部署,不用再单独配置外部 Tomcat,这对内网部署来说省了很多事。
MyBatis 在这个项目里的优势是灵活——我可以用 XML 文件写复杂的动态 SQL。图书筛选中“关键字模糊搜索 + 分类过滤 + 状态过滤”这类组合条件,用 MyBatis 的动态 SQL( <if>、 <where>、 <choose> )可以优雅地拼接,不需要在 Java 代码里搞一堆字符串拼接,也不会像 JPA 那样因为方法名太长而可读性差。MySQL 则是这个体量项目最稳妥的选择:免费、公司内网基本都有现成实例、团队里会的人也最多。
1.3 模块划分与技术亮点
我把整个系统按功能拆成五个后端模块:认证授权模块(登录、JWT签发与校验、权限控制)、用户模块(用户信息维护、角色分配)、图书模块(图书信息管理、图书实例管理、分类管理)、借阅模块(借书申请、审批、归还、逾期处理)、统计模块(借阅排行、库存统计、活跃用户)。
这套划分不是拍脑袋定的,每个模块对应一个独立的 Controller-Service-Mapper 三层结构,业务边界清晰,出现 bug 时定位极快。技术上我突出做了三件事:JWT 令牌做无状态登录认证、基于拦截器实现接口级权限校验、借阅关键操作加了数据库事务保证数据一致性。
JWT 的设计特别适合这种前后端分离场景。用户登录成功后,服务端签发一个 token 返回给前端,前端存到 localStorage,之后每个请求在 header 里带上 Authorization: Bearer <token>。服务端用拦截器解析 token、读取用户身份和角色,再判断当前用户是否有权限访问目标接口。整个过程服务端不保存 session,天然支持水平扩展——以后系统要部署多实例,不需要考虑 session 同步问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与项目初始化
2.1 前端环境搭建与版本选型
前端部分我用的是 Vue 2.6 + Element UI 2.15,没有刻意上 Vue 3。原因很实际:Element UI 是 Vue 2 生态里最成熟的中后台组件库,几乎你能想到的表格、表单、弹窗、树形控件全都有,而且网上关于它的坑和解决方案非常多。Vue 3 对应的 Element Plus 虽然也不错,但在当时做这个项目时,Element Plus 还有一些组件行为不太稳定。
Node.js 我装的是 14.x LTS 版本。这里有个细节值得提醒:当前最新的 Node.js 版本(18+、20+)和旧一点的前端工程可能存在依赖兼容问题,尤其是 node-sass 这种需要本地编译的包。我的项目直接用 sass 这个新版,避免 node-sass 在高版本 Node 下编译失败的坑。如果你要自己搭建环境,建议先装 nvm(Node Version Manager),随时切换 Node 版本,遇到依赖问题能用换版本的方式快速解决。
前端项目初始化用的 Vue CLI 4.x。vue create book-front 创建项目时我勾选了 Babel、Router、Vuex、Axios 这几个插件。Element UI 通过 npm install element-ui -S 安装,然后在 main.js 里引入:
javascript复制import Vue from 'vue'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.use(ElementUI)
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
2.2 后端环境搭建与 SpringBoot 版本选择
后端我用的 JDK 1.8 + SpringBoot 2.7.18。这个版本选择背后有一笔账:JDK 8 在 SpringBoot 2.7 下依然被完整支持,而且很多企业内部服务器装的还是 JDK 8,部署环境最兼容。SpringBoot 3.0 之后的版本强制要求 JDK 17,如果你的机器或公司服务器没升过 JDK,强行上 SpringBoot 3 会非常被动。
这里借一个常见问题说开去:很多人拿着最新版 SpringBoot 3.2 去做项目,结果发现旧教程里的 WebMvcConfigurerAdapter 不好使了、 javax 包变成 jakarta 了、MyBatis 的 starter 也要换版本了。折腾一圈下来,业务代码没写一行。SpringBoot 的版本升级带来的是整个生态的联动,如果你不是有明确的新特性需求,不要盲目追新。做企业级项目,稳定压倒一切。
后端项目直接去 Spring Initializr(start.spring.io)生成基础骨架,或者用 IDEA 的 Spring Initializr 插件创建。我选的关键依赖有:Spring Web、MyBatis Framework、MySQL Driver、Lombok、Spring Validation。创建完骨架先别急着写业务,第一件事验证最小链路——定义一个 testController 返回 hello,然后把项目跑起来,确认 8080 端口能访问。
2.3 数据库初始化与连接配置
数据库用的 MySQL 8.0。安装时要注意的是 root 用户的密码策略,MySQL 8 默认用 caching_sha2_password 认证插件,有些老的数据库客户端和驱动连接时会报错。JDBC 驱动我用的 8.0.33 版本,配合 MySQL 8 服务端完全兼容。驱动依赖我放到 pom.xml 里:
xml复制<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
建库建表之前先创建数据库:CREATE DATABASE book_share DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; 用 utf8mb4 而不是 utf8,是为了能存 emoji 表情和生僻字,这里不要在字符集上省事。然后在 application.yml 里配置数据源:
yaml复制spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/book_share?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: yourpassword
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.bootpf.bookshare.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case 开启后,数据库字段 create_time 能自动映射到 Java 属性 createTime,不用写一坨 resultMap。 log-impl 配置成 StdOutImpl 能让控制台直接打印 SQL,开发阶段调试非常方便。
3. 数据库设计与核心表结构解析
3.1 图书信息表与图书实例表的设计区分
这是整套数据库设计的核心点。我建了两张表:book_info 存图书元数据,book_instance 存物理图书。
sql复制CREATE TABLE book_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL COMMENT '书名',
author VARCHAR(100) COMMENT '作者',
publisher VARCHAR(100) COMMENT '出版社',
isbn VARCHAR(20) UNIQUE COMMENT 'ISBN号',
category_id BIGINT COMMENT '分类ID',
cover_url VARCHAR(500) COMMENT '封面图URL',
summary TEXT COMMENT '内容简介',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE book_instance (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
book_info_id BIGINT NOT NULL COMMENT '关联book_info.id',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-可借 1-已借出 2-下架',
location VARCHAR(200) COMMENT '存放位置',
qr_code VARCHAR(100) COMMENT '二维码编码',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
为什么必须拆分?想象一下公司采购了 5 本《高效能人士的七个习惯》,book_info 表里只需要一条记录存这本书的名称、作者、封面,book_instance 表里存 5 条记录分别对应实体书,各自有自己的状态和位置。借阅的时候针对的是 book_instance 的某一条记录,而不是整个 book_info。如果不拆分,同一本书 5 册的时候要么存 5 条重复的元数据,要么用一条记录里的“库存数量”字段来计数。前者数据冗余严重,后者完全没法追踪每一册书的去向。面试如果问数据库设计,这个拆分思路本身就是拿得出手的回答。
3.2 用户角色表和权限控制的库表设计
用户表我沿用了常见的设计:user 表存基本信息和账号状态,role 表存角色,sys_user_role 关联表做用户和角色的多对多映射。对这个项目来说,角色就三种:USER(普通员工)、ADMIN(图书管理员)、SUPER_ADMIN(系统管理员)。权限粒度控制在角色级别,不搞复杂的菜单权限表——内部系统的业务体量用不到那么细,过度设计也是设计失误。
users 表的核心字段如下:
sql复制CREATE TABLE user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名',
password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码',
real_name VARCHAR(50) COMMENT '真实姓名',
department VARCHAR(100) COMMENT '部门',
phone VARCHAR(20),
email VARCHAR(100),
status TINYINT DEFAULT 1 COMMENT '1-启用 0-禁用',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
密码存的是 BCrypt 加密后的结果,不是明文也不是简单 MD5。我在工具类里封装了 BCryptPasswordEncoder,注册时加密,登录时匹配。为什么不用 MD5?MD5 加盐虽然也能做,但 BCrypt 是专门为密码哈希设计的算法,内置盐值处理、运算速度可控地慢,能有效抵抗暴力破解和彩虹表攻击。企业级系统的安全底线就是密码不能明文存库。
3.3 借阅记录表与状态机的设计
借阅记录表是业务逻辑最重的表,我把状态设计成借阅流程的“状态机”:
sql复制CREATE TABLE borrow_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
book_instance_id BIGINT NOT NULL COMMENT '图书实例ID',
user_id BIGINT NOT NULL COMMENT '借阅人ID',
borrow_status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待审批 1-借阅中 2-已归还 3-已拒绝 4-超期',
apply_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '申请时间',
approve_time DATETIME COMMENT '审批时间',
approve_user_id BIGINT COMMENT '审批人ID',
expect_return_time DATETIME COMMENT '预计归还时间',
actual_return_time DATETIME COMMENT '实际归还时间',
remark VARCHAR(500) COMMENT '备注'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
状态机的价值在于让业务流转有迹可循。比如用户发起借阅,状态是 0;管理员审批通过,状态变 1,同时对应的 book_instance 的 status 也改成 1;用户归还图书,状态变 2,book_instance 的 status 改回 0。这些状态流转我都在 Service 层写死了“允许的转移路径”,非法状态转移会被直接拒绝。比如不能把“已归还”的申请再次改成“借阅中”,这种绕过前端直接调接口的行为在后端必须拦截住。
4. 后端核心功能实现
4.1 MyBatis 动态 SQL 处理图书条件搜索
图书搜索是这个系统使用频率最高的接口。前端页面有一排筛选条件:书名关键字、作者、分类、状态,用户可能任意组合这些条件,也可能一个都不填。这种“多条件任意组合”的查询正是 MyBatis 动态 SQL 的主场。
我把搜索逻辑写在 BookMapper.xml 里:
xml复制<select id="searchBooks" resultType="com.bootpf.bookshare.entity.vo.BookVO">
SELECT bi.id, bi.title, bi.author, bi.publisher, bi.cover_url,
bc.id as categoryId, bc.name as categoryName,
(SELECT COUNT(*) FROM book_instance WHERE book_info_id = bi.id AND status = 0) as availableCount
FROM book_info bi
LEFT JOIN book_category bc ON bi.category_id = bc.id
<where>
<if test="keyword != null and keyword != ''">
AND (bi.title LIKE CONCAT('%', #{keyword}, '%')
OR bi.author LIKE CONCAT('%', #{keyword}, '%')
OR bi.publisher LIKE CONCAT('%', #{keyword}, '%'))
</if>
<if test="categoryId != null">
AND bi.category_id = #{categoryId}
</if>
<if test="availableOnly != null and availableOnly == true">
AND EXISTS (SELECT 1 FROM book_instance WHERE book_info_id = bi.id AND status = 0)
</if>
</where>
ORDER BY bi.create_time DESC
</select>
这个 SQL 有几个细节值得学习。 <where> 标签会自动去掉条件前的 AND,避免写 WHERE 1=1 这种很不优雅的写法。 CONCAT('%', #{keyword}, '%') 做模糊匹配,注意不能直接写 '%${keyword}%',后者用的是字符串拼接而不是预编译参数,会有 SQL 注入风险。availableCount 这个子查询能算出每本书当前有多少本可借,前端展示“库存”数字,背后就是这条子查询。
4.2 逻辑删除与唯一索引的坑
项目里我用了逻辑删除设计——删除分类或图书时,不在数据库里真正 DELETE,而是打一个 deleted=1 标记。这么做的好处是保留所有历史借阅记录,万一哪天想恢复数据也还来得及。但逻辑删除有一个经典坑,是在做唯一约束的时候特别容易踩。
比如分类表 category 的 name 字段我本来加了唯一索引,防止建两个同名分类。但逻辑删除后某个分类被“删”了,它还在表里占用着 name 的值,这时管理员想重新创建同名的分类,插入就会报唯一索引冲突。解决办法有两个:一是唯一索引改成 (name, deleted) 联合索引;二是在应用层先查一遍有没有未删除的同名分类。前者更优雅,能利用数据库保证数据一致性。我在实际项目中用的是联合索引方式:
sql复制ALTER TABLE book_category ADD UNIQUE KEY uk_name_deleted (name, deleted);
4.3 借阅流程的并发控制与事务处理
借阅操作有一个隐藏的并发问题:同一本书只剩最后一本可借,两个用户同时点“借阅”,系统会不会把同一本书分配给两个不同的人?如果只是简单地查状态、改状态的写法,在高并发下真的可能出问题。
我的方案是借阅流程分两步。第一步用户提交借阅申请,只生成 borrow_record 状态 0 的记录,不改变 book_instance 的状态。这一步不需要锁。第二步管理员审批通过时,用一条带条件的 UPDATE 原子性操作来抢占图书实例:
sql复制UPDATE book_instance
SET status = 1
WHERE id = #{bookInstanceId} AND status = 0
关键就在 AND status = 0 这个条件。如果 UPDATE 影响行数为 1,说明抢到了,继续更新借阅记录状态为 1;如果影响行数为 0,说明图书已经被人借走了,直接返回“图书已被借出”的提示。这个写法利用了 MySQL 的行锁机制,不需要手动加 SELECT ... FOR UPDATE,更简洁也够用。
整个审批动作被 @Transactional 注解包裹,两条 SQL 要么全部成功要么全部回滚。事务保证了数据的一致性——不会出现图书被标记借出但借阅记录还是待审批状态的中间情况。
5. 前端核心功能实现
5.1 图书列表与筛选交互的实现
前端图书列表用 Element UI 的 el-table 和 el-pagination 来展示分页数据。页面结构是典型的“上搜索、中表格、下分页”。搜索区域用 el-form 做内联布局,放了三个输入框(书名、作者、分类下拉选择)和一个“仅看可借”的开关,点击“查询”按钮触发列表重新加载。
列表数据来源是后端的分页接口,我封装了一个通用的分页请求方法:
javascript复制getBookList(params) {
return request({
url: '/api/books/page',
method: 'get',
params
})
}
其中 params 包含 pageNum(当前页)、pageSize(每页条数)、keyword、categoryId、availableOnly。后端返回的数据结构是统一的 Result<T> 包装类:{ code: 200, message: 'success', data: { list: [], total: 100 } }。前端拿到数据后塞进 table 的 data 数组,total 用来驱动 el-pagination 的总数显示。
前端页面的交互有个容易忽略但又很重要的点:搜索条件变化时,页码必须重置回 1。不然用户在第 5 页搜索某个关键字,返回的结果不足 5 页,前端却还停留在第 5 页,展示空白。我在搜索按钮的 click handler 里强行 this.pageNum = 1 再调用加载方法,这个小的处理能避免用户看到“明明有数据却显示空”的诡异问题。
5.2 分享流程的前端状态管理与路由拦截
如果你去源码里看,会发现前端我用了 vuex 管理用户登录状态和用户基本信息。登录成功后存了两份数据:JWT token 存到 localStorage(持久化,刷新不丢失),用户信息对象存到 vuex(内存中,刷新后清空)。刷新页面时,路由的 beforeEach 拦截器里会先检查 vuex 里有没有用户信息,没有就从 localStorage 的 token 调一次 /api/user/info 接口拉取。
路由拦截是最核心的前端权限控制点。我在 router/index.js 中配置路由 meta 信息:
javascript复制const routes = [
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'books',
name: 'BookList',
component: () => import('@/views/book/BookList.vue'),
meta: { title: '图书列表', auth: true }
},
{
path: 'borrow/approve',
name: 'BorrowApprove',
component: () => import('@/views/borrow/BorrowApprove.vue'),
meta: { title: '借阅审批', auth: true, roles: ['ADMIN', 'SUPER_ADMIN'] }
}
]
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录' }
}
]
router.beforeEach 这个全局守卫里做的判断逻辑是:没有 token 且要去需要登录的页面,直接重定向到 /login;有 token 但目标页面配置了 roles 且当前用户角色不在其中,就重定向到首页并提示“无权限访问”。这套拦截机制保证了一个普通员工即使手抄出管理页面的路由地址传进浏览器地址栏,也进不去管理员页面。
这里建议给前端装上 vue-devtools 插件,开发调试组件数据流和 vuex 状态时它能省一大半精力。拿借阅审批这个功能举例,管理员点“通过”按钮后,查看 Vuex 中状态是否同步、Action 是否正确触发,在 devtools 的时间旅行功能里看得一清二楚。
5.3 axios 封装与统一异常处理
前端请求我统一封装了一层 axios 实例,核心是请求拦截器和响应拦截器。请求拦截器往 header 里塞 token,响应拦截器统一处理返回结果:
javascript复制import axios from 'axios'
import { Message } from 'element-ui'
import router from '@/router'
const request = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 10000
})
// 请求拦截器:加 token
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
}, error => Promise.reject(error))
// 响应拦截器:统一处理错误码
request.interceptors.response.use(response => {
const res = response.data
if (res.code !== 200) {
Message.error(res.message || '请求失败')
return Promise.reject(new Error(res.message || 'Error'))
}
return res
}, error => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token')
router.push('/login')
Message.error('登录已过期,请重新登录')
} else {
Message.error(error.message || '服务器连接失败')
}
return Promise.reject(error)
})
export default request
统一处理后,具体页面的代码就能写得很干净。接口只处理业务逻辑,不用每个地方都写 try-catch,不用担心 token 过期后弹一堆报错。这也是一种工程化思维——重复的事情交给公共代码处理,业务模块专注自己的职责。
6. 常见问题与排查技巧实录
6.1 SpringBoot 版本过高引发的兼容性问题
我在开发中遇到最常见的问题就是 SpringBoot 版本和 JDK、MyBatis 的兼容性。有个朋友用 SpringBoot 3.2 做项目,依赖里引入了 SpringBoot 2.x 的 MyBatis starter,结果报各种类找不到。排查方法很简单:启动时看报错信息里的类名,属于 javax 还是 jakarta 包。javax 是 SpringBoot 2 的用法,jakarta 是 SpringBoot 3 迁移后的命名空间。类名出现在哪边,版本就有问题。
我的建议是:做企业级管理系统,SpringBoot 就固定用 2.7.x,不要轻易上 3.x。如果你真的需要 SpringBoot 3 的特性,那 MyBatis 的 starter 也要换成对应的新版本,JDK 升级到 17,同时用 spring-boot 2.7.18 时同样要注意配套版本,不要随意对标网上新教程。版本统一比功能新更值钱——出现一个 bug,你能在搜索引擎找到大量答案,这就够了。
6.2 MyBatis 查询结果为空或字段值为 null 的排查
这是一类高频问题:SQL 明明在数据库客户端里能查出数据,但通过 MyBatis 查出来却是 null,或者集合是空的。排查的切入点是打开 MyBatis 的 SQL 日志(前面我在 application.yml 里配了 StdOutImpl),看打印出来的 SQL 是不是符合预期。如果 SQL 本身没问题,那大概率是字段映射问题——数据库字段是 create_time,Java 实体是 createTime,如果没开启 map-underscore-to-camel-case 且没写 resultMap,映射就会失败返回 null。
还有一类情况是动态 SQL 的 <if test="..."> 判断条件写错。比如我传的参数是 Integer 类型的 status,判断时写成了 status == '0',MyBatis 在 OGNL 表达式里会把它当成字符串比较,数字 0 永远不等于字符串 '0',条件永远不成立,查询结果自然不对。排查这类问题时,把打印出来的 SQL 和预期对比,一眼就能定位。
6.3 Vue 项目运行报错与依赖问题
前端项目最常见的坑是 npm 依赖安装失败或运行时报错。新版本 Node.js 下安装依赖经常遇到 ERR_OSSL_EVP_UNSUPPORTED 之类的报错,这是因为新版 Node 里 OpenSSL 的加密算法变更导致的。临时解决办法是运行 export NODE_OPTIONS=--openssl-legacy-provider,但根本办法还是用 Node 14/16 这类旧版 LTS。
还有 vue-cli-service 命令找不到的问题,多半是 node_modules 没装全或者 node_modules 里有依赖冲突。我常用的处理顺序是:删掉 node_modules 和 package-lock.json 重新 npm install,换 npm 源为国内镜像源,最后再考虑用 yarn 替代 npm。Vue 组件库的样式加载顺序也值得留意:Element UI 的样式必须在业务样式之前引入,否则自定义样式会被组件库覆盖。把这两个坑提前说清楚,后续开发会顺很多。
6.4 MySQL 安装配置与连接失败自查
在公司内网部署,遇到最多的问题是 MySQL 的 root 账号不能远程登录,或者用 Navicat/Workbench 连接时报 Access denied。排查时需要确认三件事:MySQL 服务是否启动、root 用户是否允许对应来源 IP 连接、MySQL 端口 3306 是否被防火墙拦截。
测试连接的命令行诊断方法很直接,在服务器上用 mysql -uroot -p -h127.0.0.1 -P3306 先本地测一遍,再换成局域网 IP 测一遍。如果本机能连而远程不能,百分之九十九是 root 用户 host 是 localhost 或者 bind-address 只绑了 127.0.0.1。前者改用户的 host 权限,后者改 my.cnf 的 bind-address 为 0.0.0.0。刚学的时候被这类问题卡住,但一旦掌握排查路径,5 分钟就能解决,后面再也不会慌。
7. 部署方式与后续扩展建议
7.1 前后端分离项目的打包与部署
项目做完之后,部署本身不难,但有一些细节要处理好。后端打包用 Maven 的 package 命令,生成可执行 jar,然后 nohup java -jar book-share.jar --spring.profiles.active=prod > app.log 2>&1 & 后台启动。生产环境的配置我单独放在 application-prod.yml 里,数据源地址、日志级别都跟开发环境区分开。
前端打包用 npm run build,打包产物在 dist 目录里,是一个纯静态文件集合。我把 dist 目录交给 Nginx 托管,然后给前端配一个反向代理: /api 开头的请求转发到后端的 8080 端口,其他请求直接访问 dist 里的静态文件。这个代理配置同时也解决了前端开发时的跨域问题。前端部署后,浏览器访问 http://服务器IP 就能进入系统。
7.2 企业管理系统的后续演进方向
系统做完了,并不意味着工作结束。如果后续真要落地到公司内部使用,我有几个扩展建议。第一是增加图书封面和用户头像的文件上传能力,把本地磁盘存储换成 OSS 或 MinIO 这类对象存储,不然图片多了服务器磁盘会扛不住。第二是增加消息通知能力——借阅审批通过或拒绝时,通过站内信、邮件甚至企业微信推送给借阅人,这会让系统的体验完整很多。第三是统计模块可以做得更细,比如按部门统计借阅排行、按分类统计热门方向,这些数据对组织内部营造读书氛围很有参考价值。
我个人在实际操作中的体会是,一个小系统做得好不好,不在于功能多不多,而在于每个环节想明白“为什么这样做”。比如数据库的表拆分、状态机的转移限制、并发场景下的原子性更新,这些设计如果没有想清楚,后面补起来要付出更大的代价。最后再分享一个小技巧:写这类前后端分离系统的时候,一定先把后端的接口文档定好,哪怕只是用一个 Markdown 文件列清楚每个接口的地址、入参、出参,前后端并行开发时会顺畅很多。这也是我这次项目整体效率较高的重要原因。
