最近在帮几个准备做毕业设计的同学看项目,发现好几个人不约而同选了“学校物资管理系统”这个方向。这类管理系统在课设、毕设里确实非常常见,但大多数同学找到的参考项目要么只有后端没有前端,要么前端直接用非前后端分离的模板渲染,能真正把 SpringBoot + Vue + MyBatis + MySQL 这一套完整打通、并且附带清晰部署教程的项目并不多。这次要拆解的《前后端分离学校防疫物资管理平台系统》,正好是一套能跑通全流程的完整项目:后端是 SpringBoot + MyBatis + MySQL,前端是 Vue 全家桶,源码结构清晰,SQL 脚本、启动步骤、服务器部署说明都给你备齐了。
我会按自己实际阅读这套项目并动手部署的思路,把业务模块、技术选型逻辑、后端核心实现、前端写法、本地启动步骤、云服务器部署流程,以及我真实跑项目时踩过的坑一条条讲清楚。如果你打算拿它当毕设底子,或者想通过一个完整案例把前后端分离的技术栈从头到尾串一遍,按这个顺序读下来会顺很多。
1. 从需求到模块:学校防疫物资平台到底要管哪些事
我每次拿到一个项目,第一件事都不是打开代码,而是先看业务。业务理顺了,代码看起来才会有章法,改起来也知道改哪里。很多同学一上来就翻 entity、翻 controller,翻完就懵了:这么多类,到底谁调用谁?
先把这个项目的业务场景说清楚。学校这个场景下,物资管理通常绕不开三类角色:
- 系统管理员:负责账号开设、基础数据维护、查看全量数据,权限最高。
- 物资管理员/库管员:负责日常入库、出库、盘点,维护物资台账。
- 普通教职工/院系申请员:可以浏览可用的物资列表,提交领用申请,查看自己名下的记录。
传统做法里,这些工作大多靠纸质台账和 Excel 完成。问题是显而易见的:库存数字实时性差、领用记录容易丢、月底统计报表全靠手工算、某类物资快用完时也没人及时知道。这个平台要做的就是把这些线下流程搬上线,让每一次入库、出库都有记录,让库存数字可查、可预警、可统计。
1.1 功能模块总览
从标题和源码结构来看,项目功能基本可以分为下面这些模块,我按重要程度排了个序:
| 模块 | 功能描述 |
|---|---|
| 登录与权限 | 基于 JWT 的登录认证,按角色区分菜单权限和接口访问权限 |
| 物资类别管理 | 维护物资的分类信息,支撑物资台账的分类筛选 |
| 物资台账管理 | 物资的新增、修改、删除、查询,包含名称、规格、单位、库存、安全库存等字段 |
| 入库管理 | 入库单录入,保存后自动累加对应物资的库存,并写入入库流水 |
| 出库管理 | 出库单录入,校验库存充足后自动扣减库存,并写入出库流水 |
| 库存预警 | 物资库存低于安全库存阈值时,在列表和首页给出预警提示 |
| 统计报表 | 基于入库/出库流水数据生成图表,比如分类占比、出入库趋势 |
| 申请审批 | 教职工提交物资领用申请,管理员审核通过后自动出库 |
1.2 核心业务流程
整个系统最核心的一条链路是:申请人员选择物资、填写数量,提交申请;管理员审核;审核通过后扣减库存,生成出库记录;同时判断库存是否低于安全库存,触发预警。
这条链路看起来简单,但落到数据库和代码上,有两个关键点必须处理到位:一个是扣库存时的并发问题,另一个是事务问题。如果只是简单地“先查询库存,再判断够不够,再扣减”,在高并发或多人同时操作时很容易超领。后面讲后端实现时我会专门展开这部分,这也是这个项目里比较值得学习的地方。
2. 技术选型复盘:SpringBoot+Vue+MyBatis+MySQL为什么是主流组合
先说明一点:这套技术栈不一定每个环节都是“最好的”,但它胜在资料多、上手快、市场上认知度高。老系统还在用 SSH 或 SSM 那一套,新项目尤其是中小型管理系统,SpringBoot 基本是默认选择;前端从 jQuery 开发模式演进到组件化之后,Vue 和 React 成了主流;ORM 层 MyBatis 则是国内 Java 圈子讨论最多、面试也最爱问的一个。所以这套组合用于学习和毕设,性价比非常高。
2.1 后端:SpringBoot 到底省了哪些事
如果你用过传统 SSM 项目,一定记得那些繁琐的 XML 配置:web.xml、spring-mvc.xml、mybatis-config.xml、数据源配置,一不小心漏掉一个 bean 就启动失败。SpringBoot 用“约定优于配置”的思路把这些大量简化了。
- 整合了内嵌的 Tomcat,打成一个 jar 包就能直接跑,不需要单独装容器。
- 通过 starter 依赖一键引入 Web、MyBatis、MySQL 驱动等能力,不用自己手工处理版本冲突。
- application.yml 一个文件搞定数据源、端口、MyBatis 映射等配置。
- 自带 Spring MVC 的自动配置,写 Controller 时基本不用继承什么父类。
对于这个项目来说,SpringBoot 2.x 配合 MyBatis 是最稳妥的组合,网上能查到的问题解决方案也最多,比追新版踩坑要省心得多。
2.2 前端:Vue 的价值在哪
Vue 在这个项目里承担的是典型的 SPA(单页面应用)开发模式。它把页面拆成组件,靠数据驱动视图更新。用户看到的不是一个个独立的 HTML 页面被反复刷新,而是应用启动后,前端路由器根据 URL 动态加载对应组件,通过 AJAX 请求后端接口拿数据,再渲染到页面上。
组件化带来的直接好处是复用。比如物资列表表格、入库出库弹窗、分页条这些在多个页面会用到的东西,抽成组件后,写一次可以到处用。对毕设级的项目来说,Vue 的生态也足够友好:Element UI 提供现成的表格、表单、弹窗组件,ECharts 提供图表,路由和状态管理都有成熟方案,不需要从零造轮子。
2.3 ORM 层:为什么选 MyBatis 而不是 JPA / MyBatis-Plus
这是评论区最容易吵起来的话题。我的看法是:对于想搞懂 SQL、想应对面试、想灵活控制查询的人来说,原生 MyBatis 其实更适合学习。它把 SQL 写在 mapper.xml 里,你可以精确控制每一条 SQL 的写法,遇到复杂多表查询、动态条件拼装时优势很明显。JPA 虽然开发快,但自动生成的 SQL 对新手来说像个黑盒,出了问题不好排查。
MyBatis-Plus 确实更省事,CRUD 方法直接能用,但标题写的是 MyBatis,那我们就在 MyBatis 的体系下讲。学习阶段把 MyBatis 的if、where、foreach这些动态 SQL 用熟,以后切到 MyBatis-Plus 也非常快,两者本质是同一个底层思想。
2.4 整体部署形态
前后端分离的项目,最终部署出来一般是这个形态:
- Vue 项目执行构建后,生成一堆静态文件(HTML、CSS、JS),部署到 Nginx 静态目录。
- Nginx 同时承担反向代理的角色:当浏览器请求
/api/xxx时,把请求转发给后端的 SpringBoot 服务。 - SpringBoot 通过 MyBatis 操作 MySQL 数据库,返回 JSON 给前端。
这样做的好处是:前端静态资源可以走 Nginx 的高并发处理,后端服务只负责业务逻辑,两者可以独立部署、独立扩容。开发的时候,前端自己起一个开发服务器,通过代理转发解决跨域,也完全不影响联调。
3. 后端编码实录:数据表设计、登录鉴权和库存业务核心逻辑
后端代码是整个系统的地基。我推荐按“数据库 → 实体 → Mapper → Service → Controller”的顺序去看,这样最不容易乱。
3.1 数据表设计
从典型的实现来看,项目里会包含这些核心表,我先列一个表说明用途:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| sys_user | 系统用户 | id, username, password, nickname, role, status |
| category | 物资类别 | id, name, remark |
| material | 物资台账 | id, category_id, name, spec, unit, stock, safe_stock, update_time |
| stock_record | 出入库流水 | id, material_id, type(1入库/2出库), quantity, operator, remark, create_time |
| apply_order | 领用申请单 | id, user_id, material_id, apply_num, status, audit_user, audit_time |
其中material表的stock字段是当前实时库存,safe_stock是安全库存阈值。stock_record表用于记录每一次出入库的明细,是统计报表的数据来源。apply_order表用于审批流:申请记录先落库,状态为待审核,管理员审核通过后才真正扣减库存。
关键表的建表 SQL 大概是这个风格,我贴一部分供参考:
CREATE TABLE `material` ( `id` int NOT NULL AUTO_INCREMENT, `category_id` int DEFAULT NULL COMMENT '分类ID', `name` varchar(100) NOT NULL COMMENT '物资名称', `spec` varchar(100) DEFAULT NULL COMMENT '规格型号', `unit` varchar(20) DEFAULT NULL COMMENT '计量单位', `stock` int NOT NULL DEFAULT 0 COMMENT '当前库存', `safe_stock` int NOT NULL DEFAULT 10 COMMENT '安全库存阈值', `remark` varchar(255) DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物资台账表';这里有个细节:所有表的字符集尽量用utf8mb4,因为 MySQL 的utf8编码只能存 3 个字节,遇到 emoji 或者特殊生僻字会报错。表字段按实际业务取,不用追求多,够用且完整最好。
3.2 登录与 JWT 鉴权
登录流程并不复杂:前端把用户名密码 POST 给后端,后端用username查出用户记录,用 BCrypt 对密码做比对,比对通过后生成一个 token 返回给前端。前端把这个 token 存起来,之后每次请求都放在 Header 里,后端通过拦截器统一校验。
为什么用 JWT 而不是传统的 Session?因为前后端分离以后,前端可能跑在 8081,后端跑在 8080,Session 依赖 Cookie,跨域场景下 Cookie 的维护成本比较高。JWT 把用户信息签名进 token 里,后端不保存状态,服务端只需要校验签名和过期时间即可,天然适合这类分布式的部署形态。
拦截器的核心思路是拦截/api/**下的所有请求,从请求头取Authorization字段,解析 token,校验通过后把当前用户信息放到ThreadLocal里,方便 Controller 直接获取当前操作人信息。
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !JwtUtil.verify(token)) { response.setStatus(401); return false; } // 解析token拿到当前用户id和角色,放入ThreadLocal return true; } }角色权限这里,常见的做法是给不同角色分配不同的菜单和接口权限。简单实现可以用自定义注解@RequirePermission("admin")配合 AOP 做接口级校验,也可以在拦截器里判断角色字符串。具体实现程度看源码怎么写的,但思路一定是“每个受保护接口必须经过认证,敏感接口必须校验角色”。
3.3 库存扣减的并发事务处理
这段是整个后端业务里我认为最值得学习的部分,因为它是典型的“看起来简单、做起来容易出错”的逻辑。
很多人一开始会这么写:
Material material = materialMapper.selectById(materialId); if (material.getStock() < num) { throw new RuntimeException("库存不足"); } material.setStock(material.getStock() - num); materialMapper.updateById(material);这个写法有一个问题:如果在判断完库存之后、执行更新之前,另一个请求也读到了同一份数据,两个请求都会认为库存充足,都会去扣减,最终库存就变成负数了。这在并发场景下叫“超卖”。
更稳妥的做法是把判断下推到 SQL 里,配合事务一起使用:
UPDATE material SET stock = stock - #{num}, update_time = NOW() WHERE id = #{materialId} AND stock >= #{num}这条 SQL 的意思是:只有当当前库存大于等于本次出库数量时,才执行扣减。MySQL 的行锁会保证同一时间只有一个请求能成功更新这一行,更新后返回的影响行数如果为 0,说明库存不足,直接抛异常、回滚事务。
事务则通过 Service 层的@Transactional来保证:扣减库存、写入流水、更新申请单状态,这三步要么全部成功,要么全部失败。
@Transactional(rollbackFor = Exception.class) public void auditApply(ApplyOrder order) { int rows = materialMapper.deductStock(order.getMaterialId(), order.getApplyNum()); if (rows == 0) { throw new RuntimeException("审核失败:库存不足"); } stockRecordMapper.insert(new StockRecord(order.getMaterialId(), 2, order.getApplyNum(), "管理员", "审核通过出库")); applyOrderMapper.updateStatus(order.getId(), 2); }这里有一个新手高频踩坑点:@Transactional只有在异常被抛出时才会回滚,如果你在事务方法内部自己try...catch把异常吃掉,事务是不会回滚的。所以要让异常抛出方法外,或者手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
3.4 MyBatis 动态 SQL 与分页
物资管理页面有一个高频需求,就是多条件组合查询:按名称模糊查询、按分类筛选、按库存状态筛选。MyBatis 处理这种动态条件非常舒服,用<where>配合<if>标签就可以:
<select id="selectByCondition" resultType="com.example.entity.Material"> SELECT * FROM material <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="stockStatus == 1"> AND stock < safe_stock </if> </where> ORDER BY update_time DESC </select>分页一般直接引入 PageHelper 插件,在 Service 层先PageHelper.startPage(pageNum, pageSize),然后执行查询,返回的结果会被自动包装成带total的分页对象。注意分页插件必须紧跟一条查询语句,中间如果穿插其他查询,会拿到错误的分页结果。
另外,MyBatis 的一级缓存是 SqlSession 级别的,同一个 SqlSession 中执行相同的查询会走缓存;二级缓存默认关闭,开启后要特别注意:如果手动改了数据库数据但缓存没刷掉,会查到旧值。库存这种变化频繁的数据,不建议开二级缓存。
4. 前端编码实录:路由守卫、API 封装和物资管理页面
前端部分的核心思路是:把网络请求统一封装,把页面按路由拆开,把可复用的 UI 片段抽成组件,然后通过接口和数据串起来。
4.1 前端目录结构
一套典型 Vue 项目的源码组织方式大致是:
src/ ├── api/ # 每个模块的接口请求函数 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置 ├── store/ # 状态管理,比如用户信息、token ├── utils/ # axios封装、工具函数 └── views/ # 页面,登录页、物资管理、入库出库、统计报表等4.2 axios 请求封装
axios 封装是整个前端项目的“水管”,每个请求都会经过它。封装时最关键的几个点:
- 设置
baseURL,开发环境指向代理前缀/api,生产环境同样是/api,让 Nginx 统一转发。 - 请求拦截器里从 localStorage 拿 token,统一加到请求头
Authorization。 - 响应拦截器里统一处理后端返回的 JSON 结构,比如
code === 200才算成功;遇到 401 状态码,清掉本地 token,跳转登录页。
const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.msg || '请求失败')) }, error => { return Promise.reject(error) } )4.3 路由与权限控制
前后端分离项目里,路由控制分两个层面:第一层是未登录的人不能进系统;第二层是登录用户只能看到他角色对应的页面。
Vue Router 的全局前置守卫可以统一处理这件事:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else { next() } })如果需要按角色控制菜单,可以在路由配置里给每个页面标记meta.roles,然后在守卫里比对当前用户的角色。
这里要留意一个部署相关的问题:Vue Router 如果用的是history模式,URL 里没有#号,看起来更干净,但部署到服务器时,如果用户直接刷新一个子页面比如/material,Nginx 会去服务器上找这个路径对应的文件,找不到就 404。解决办法是在 Nginx 里加try_files $uri $uri/ /index.html;。这个问题后面部署章节会再提。
4.4 核心页面拆解
登录页相对简单,用el-form做表单校验,提交后调登录接口,拿到 token 存起来,跳转首页。
物资管理页则是一个标准的“搜索区 + 表格 + 分页 + 操作按钮”结构。搜索区包含物资名称、分类、库存状态,点查询后重新请求列表;表格用el-table渲染,每一行有入库、出库、编辑、删除按钮;分页用el-pagination,切换页码或页大小后重新拉列表。
入库、出库一般用el-dialog弹窗实现。表单里选择物资、填写数量、填写备注,提交后调对应接口。提交成功之后要做两件事:刷新当前列表,把刚才的库存变化反映出来;同时如果有必要,刷新首页的预警统计。
整个前端最容易忽略的其实是数据更新后的 UI 一致性。比如出库成功后,库存数字变了,但如果你只刷新了当前页表格,没有同步更新首页的统计卡片,用户就会觉得数据对不上。这里建议把获取列表和获取统计抽成单独的方法,在操作成功后按需调用。
5. 本地跑通全流程:环境配置、SQL 脚本导入和项目启动
不管是学习还是做毕设,第一步肯定是先把项目在本地跑起来。本地跑通了,后面的改造和二次开发才有基础。
5.1 前置环境版本建议
这套技术栈我用下来,版本匹配建议是这样的:
| 软件 | 建议版本 | 备注 |
|---|---|---|
| JDK | 1.8 | 稳定,兼容性最好 |
| Maven | 3.6+ | 管理后端依赖 |
| Node.js | 14.x / 16.x | 太新的 Node 有时和旧依赖不兼容 |
| MySQL | 5.7 / 8.0 | utf8mb4 字符集 |
| IDEA | 2020+ | 自带 Spring 插件 |
| 前端包管理器 | npm | 源不稳定时用淘宝镜像 |
MySQL 8.0 和 5.7 在连接配置上有个小区别:8.0 的驱动类是com.mysql.cj.jdbc.Driver,5.7 是com.mysql.jdbc.Driver。用 8.0 时建议在 JDBC URL 后面加上serverTimezone=Asia/Shanghai,否则会报时区错误。
5.2 创建数据库并导入 SQL 脚本
以 MySQL 5.7/8.0 为例,先在命令行或 Navicat 里创建数据库:
CREATE DATABASE IF NOT EXISTS school_material DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后导入项目里自带的 SQL 脚本。项目的 SQL 文件一般在db/或sql/目录下,用 Navicat 直接运行即可。导入完成后,应该能看到 sys_user、material 这些表,并且 sys_user 表里有一条初始化的 admin 账号。
这里我建议你拿到项目后先检查一下 SQL 脚本里sys_user的密码字段,确认是明文还是 BCrypt 加密串。如果是加密串,那初始密码一般在部署文档里有说明;如果是明文,那你本地跑通后第一件事就应该改成 BCrypt 加密存储,避免安全问题。
5.3 后端启动前必须检查的配置
用 IDEA 打开后端目录,等 Maven 下载完依赖后,重点检查application.yml:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/school_material?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 你的数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true jwt: secret: 你的签名密钥 expire: 7有几个容易踩的细节:
map-underscore-to-camel-case: true这个配置非常重要。数据库字段是category_id,Java 属性是categoryId,不开启下划线转驼峰,查询结果映射出来 categoryId 永远是 null。- mapper-locations 路径要和实际放置 mapper.xml 的目录一致,不一致启动会报
Invalid bound statement。 - JWT 的 secret 自己随便改一个长字符串,不要用默认值。
5.4 启动后端与接口验证
配置没问题后,直接运行 SpringBoot 的启动类,看到 “Started xxApplication” 日志就说明启动成功了。为了验证登录接口,可以用 Postman 或 Apifox 发一个 POST 请求:
POST http://localhost:8080/api/login Content-Type: application/json { "username": "admin", "password": "123456" }正常情况下会返回一个 token。拿这个 token 作为Authorization请求头,再去请求用户列表或物资列表接口,如果能拿到数据,说明后端这套链路是通的了。
5.5 启动前端
前端目录下执行:
npm install npm run servenpm install时间比较长,如果网速慢会卡住,建议先用npm config set registry https://registry.npmmirror.com切换成国内镜像。
启动后访问http://localhost:8081,用初始账号登录,然后把物资列表、新增物资、入库、出库、统计报表都点一遍,确认主流程没有问题。开发环境下,前端的请求会通过vue.config.js里的 devServer.proxy 转发到http://localhost:8080,这样本地开发是不存在跨域问题的。
6. 云服务器部署实测:jar包打包、Nginx代理与进程守护
本地没问题以后,很多人会想把项目部署到云服务器上,一方面是毕设答辩时需要演示线上效果,另一方面也是简历上的一个亮点。这步比想象中简单,但也比想象中容易踩坑。
6.1 项目打包
后端打包,在项目根目录执行:
mvn clean package -DskipTests打包完成后,target目录下会生成一个xxx.jar,这就是后端服务的可运行产物。
前端打包,在前端目录执行:
npm run build完成后会生成一个dist目录,里面是编译好的静态文件。后面需要把这个dist上传到服务器,或者直接打包上传然后解压。
有一个点必须提醒:如果前端项目里的接口地址写的是http://localhost:8080/api这种开发环境地址,打包前一定要改成/api或者用环境变量区分生产环境。否则部署到服务器以后,浏览器访问的是服务器的页面,但接口请求却指向了用户本地的 8080,必然失败。
6.2 服务器环境准备
服务器系统一般用 CentOS 7.x 或 Ubuntu 20.04,需要在上面装好 JDK、MySQL、Nginx。没有图像界面的环境下,基本都用命令行操作:
# 以 Ubuntu 为例 sudo apt update sudo apt install openjdk-8-jdk mysql-server nginxMySQL 安装好之后,创建数据库并导入和本地一样的 SQL 脚本。要注意的是:服务器上的 MySQL 默认只监听本地,如果后端服务和数据库在同一台机器上,那 Java 里的 JDBC URL 配置localhost就好;如果你打算让本地连服务器的数据库,需要改 MySQL 用户权限和防火墙,但出于安全考虑,不建议在生产环境开放数据库远程端口。
6.3 Nginx 配置要点
把前端dist目录里的内容上传到服务器的某个目录,比如/usr/share/nginx/html。然后编辑 Nginx 配置:
server { listen 80; server_name 你的服务器IP或域名; root /usr/share/nginx/html; index index.html; # 前端刷新404问题 location / { 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里有一个经典陷阱:proxy_pass末尾到底加不加斜杠。如果写的是proxy_pass http://127.0.0.1:8080;(不带斜杠),请求/api/login会原样转发给后端,变成http://127.0.0.1:8080/api/login,这就要求后端 controller 的 RequestMapping 包含/api前缀;如果写的是http://127.0.0.1:8080/;(带斜杠),前缀会被替换掉,请求就变成http://127.0.0.1:8080/login。项目里如果后端接口统一带/api,那就不带斜杠,直接用第一种写法,最不容易出错。
配置完执行nginx -t检查语法,然后systemctl reload nginx让配置生效。
6.4 后端 jar 包的进程守护
后端不能直接java -jar xxx.jar跑完就不管了,因为一旦关掉终端进程就没了。更规范的做法是用 systemd 把它注册成系统服务。
sudo vim /etc/systemd/system/school-material.service写入:
[Unit] Description=School Material Management System After=network.target mysql.service [Service] ExecStart=/usr/bin/java -jar /opt/app/school-material.jar Restart=on-failure User=root [Install] WantedBy=multi-user.target然后:
sudo systemctl daemon-reload sudo systemctl start school-material sudo systemctl enable school-material sudo systemctl status school-material用journalctl -u school-material查看日志。如果进程启动失败,多半是数据库连接地址、账号密码写错了,或者端口 8080 被占用。
6.5 部署后的验证与常见问题
部署完成后,浏览器访问http://服务器IP,应该能看到前端登录页,输入账号密码能正常进入系统。如果登录时报错,按顺序排查:
- 后端服务是否活着:
systemctl status school-material,或者直接curl http://127.0.0.1:8080/api/login看有没有响应。 - Nginx 日志有没有报错:
tail -f /var/log/nginx/error.log。 - 防火墙有没有放行 80 端口:云服务商的安全组也需要在控制台放行。
- 数据库连接是否正常:看后端日志里有没有 Connection refused。
7. 免踩雷经验:跨域拦截、刷新404和库存并发问题
最后这部分,是我在这次实战中觉得最有价值的内容。很多问题本身不难解决,但没踩过的人根本想不到会在哪里栽跟头。
7.1 本地开发时的跨域处理
前后端分离必然面对跨域。本地开发时最好的解决方案不是在后端加一个允许所有来源的 CORS 配置,而是用 Vue 脚手架自带的代理能力。在vue.config.js里配置:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这样前端发请求时所有/api开头的请求都由开发服务器转发到后端,浏览器始终只和localhost:8081通信,不存在跨域。如果还需要后端支持外部调用,再额外加一个 CORS 配置,但要限制允许的来源,不要图省事allowedOrigins("*")。
7.2 前端刷新页面 404
这个问题在 history 路由模式下必现。开发时没问题,因为 Vue 开发服务器会把所有路径回退到 index.html;部署后 Nginx 不会,所以必须在静态文件配置里加try_files $uri $uri/ /index.html;。
如果你发现部署后刷新首页没问题,刷新http://IP/material这种深层路径就 404,基本都是这个配置没加。加完nginx -t && systemctl reload nginx就能解决。
7.3 登录过期后页面表现异常
如果后端返回 401 而前端没有统一处理,常见的表现是:页面某个请求报错,但用户还停在当前页面,操作一次报错一次,再刷新就直接跳回登录页甚至白屏。
好的做法是在 axios 响应拦截器里统一处理 401:清除本地 token、跳转登录页,并且最好用window.location.href强制刷新,避免 Vue Router 内存路由状态和实际页面状态不一致。
7.4 库存并发问题的复现与自测
库存扣减的逻辑写完,建议你用 JMeter 或 Postman 的并发测试功能测一下:同一个物资,设置库存为 10,然后开 20 个线程同时提交出库 1 个,看最终库存是不是 0,是不是只有 10 个请求成功。如果扣出了负数,说明你的扣减逻辑还是“先查后扣”,不是“条件更新”。
我自己实测下来,用UPDATE material SET stock = stock - #{num} WHERE id = #{id} AND stock >= #{num}这种写法,配合事务,并发场景下基本不会出问题。这也是这个项目里最值得拿出来讲的一个点,面试时跟面试官聊清楚,很加分。
7.5 编码和时区问题
中文乱码和时区错误也是部署阶段的常客。JDBC URL 里一定要带这三件套:
useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/ShanghaiMySQL 建库时指定utf8mb4,前端页面meta里指定charset="utf-8"。三个环节都对了,中文一般不会乱。
最后再分享一个实用的扩展方向。如果你打算拿这套项目做二次开发,我首推给申请审批流程接上工作流引擎,比如 Flowable,把现在简单的“管理员审核”升级成多级审批流;其次是给物资管理加上 Excel 导入导出,这个在真实勤务场景里几乎是刚需;然后就是统计报表的升级,从固定图表改成可配置的数据看板。这些扩展方向都能让你的毕设或项目经历在答辩时更有说头。
我个人最大的体会是:这种管理系统看上去“基础”,但它把权限认证、事务控制、前后端联调、服务器部署这些工程化知识完整地串到了一起。能把这套项目从头到尾彻底跑通、再改出一个自己的亮点功能,比闷头刷几十道面试题要更有底气。如果你在启动或部署时遇到了具体报错,别慌,按数据源、端口、Nginx 转发、后端日志这个顺序排查,基础环境对了,后面基本就顺了。