做了几年开发,前后端分离的项目没少碰,但要说哪种项目最适合拿来练手和复用,语言考试信息报名系统绝对算一个。业务闭环完整、权限边界清晰、数据模型典型,而且从注册、报名、审核到成绩查询,整条链路天然适合拆成一套标准的前后端分离架构。我这次用SpringBoot+Vue+MyBatis+MySQL把这套系统完整实现了一遍,源码和部署教程都整理好了。这篇就把整个项目的设计思路、核心实现、部署细节和踩坑记录一次讲清楚,想照着写一个类似系统的朋友可以直接参考。
1. 项目整体思路与技术选型
1.1 为什么这个系统一定要用前后端分离
先聊个基础问题:语言考试报名系统这种业务,用传统JSP或者Thymeleaf服务端渲染不行吗?能行,但会很痛苦。
这类系统的特点非常明显:考生端和管理端的页面形态差异巨大,考生看的是报名流程、缴费状态、准考证打印,管理员看的是数据表格、审核操作、统计分析。如果全部由后端渲染,每次页面切换都要走一次服务端视图解析,前后端代码耦合在一起,改一个按钮样式都可能要动Java代码,后期维护是灾难。
前后端分离之后,后端只负责提供JSON接口,前端拿到数据自己渲染。Vue负责页面交互和路由,SpringBoot只处理业务逻辑和数据,两者通过HTTP协议通信。这样做的直接收益有三个:一是前端开发和后端开发可以完全并行,不用互相等;二是同样的接口可以同时服务浏览器端和后续的移动端,比如以后要做小程序报名通道,后端接口零改动;三是部署的时候前端静态资源扔到Nginx,后端打成jar包单独跑,互不影响,扩容也方便。
我见过不少团队把报名系统做成服务端渲染,到了报名高峰期几十万考生同时刷页面,数据库和模板引擎全被拖垮,临时加机器还只能整包复制,相当被动。前后端分离以后,前端静态页面直接用CDN加速,后端接口做集群,性能瓶颈的定位和处理都清晰得多。
1.2 SpringBoot、Vue、MyBatis、MySQL的组合逻辑
技术选型这件事,很多时候不是选最好的,而是选最稳的。SpringBoot+Vue+MyBatis+MySQL这套组合,放在今天依然是中小型管理系统的黄金搭档。
- SpringBoot负责后端骨架。它解决了Spring家族配置地狱的问题,内嵌Tomcat,一个jar包就是整个服务,应用启动快,生态成熟。报名系统涉及的定时任务、邮件通知、Excel导出这些功能,SpringBoot都有现成的starter可以引入。
- Vue负责前端交互。响应式数据绑定让报名表单、审核列表这类强交互页面写起来非常顺手,组件化开发方式也让页面复用变得容易。我这次用的是Vue3+Vite,构建速度比Vue2的webpack方案快一大截,开发体验提升明显。
- MyBatis负责数据库访问。相比JPA那种全自动ORM,MyBatis的半自动映射对SQL可控性更强。报名系统里有大量复杂的统计查询,比如按考试类型统计报名人数、按考点查看座位余量,这些SQL用MyBatis的XML文件管理得一目了然,调优也方便。
- MySQL负责数据持久化。报名系统的数据量充其量到百万级别,MySQL完全扛得住,成熟的索引优化和主从复制方案足够支撑业务增长,而且部署运维成本低,团队里的每个人都能上手。
这套技术栈还有一个隐性的好处:招人容易。国内大部分Java开发都熟悉这套组合,项目交接到别人手里,不需要重新学一套冷门框架。
1.3 项目目录结构一览
我习惯从目录结构开始规划项目,结构定了,开发顺序就清楚了。这份源码里前后端分两个目录:
code复制exam-registration-server # 后端SpringBoot工程
├── src/main/java/com/exam
│ ├── controller # 接口层
│ ├── service # 业务逻辑层
│ ├── mapper # MyBatis数据访问接口
│ ├── entity # 实体类
│ ├── config # 配置类(跨域、拦截器、分页等)
│ ├── common # 通用返回结构、异常处理、工具类
│ └── security # JWT登录认证与权限控制
├── src/main/resources
│ ├── mapper # MyBatis XML映射文件
│ └── application.yml # 核心配置
└── pom.xml
exam-registration-web # 前端Vue工程
├── src
│ ├── api # 接口请求封装
│ ├── router # 路由配置
│ ├── store # Pinia状态管理
│ ├── views # 页面组件(考生端+管理端)
│ ├── components # 公共组件
│ └── utils # 工具函数和axios实例
├── vite.config.js # 开发代理与构建配置
└── package.json
这个结构是按照职责分层来拆的,后端严格遵循三层架构:Controller只做参数接收和结果返回,Service层处理业务规则,Mapper层专注数据访问。前端按页面功能分为考生端和管理端两部分,通过路由和权限控制区分访问范围。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心业务与数据库设计
2.1 功能模块拆解:考生端和管理端双视角
报名系统的业务流程要先理清楚,再动手写代码。我从用户角色角度把功能拆成两条线。
考生端的功能设计:
- 注册登录:手机号+密码注册,密码用BCrypt加密存储。登录后签发JWT Token,前端后续请求都带上这个Token。
- 考试报名:选择正在开放报名的考试计划,填写个人信息、上传证件照、选择考点,提交后生成报名记录。
- 报名状态跟踪:考生能实时看到自己的审核状态,待审核、审核通过、审核驳回、已缴费这几个状态一目了然。
- 准考证下载:审核通过并缴费后,系统生成准考证,考生可在线查看和打印。
- 成绩查询:考试结束后管理员录入成绩,考生在个人中心查询成绩和合格状态。
管理端的功能设计:
- 考试计划管理:管理员创建考试,设置报名开始时间、结束时间、考试时间、考试费用、报考条件等参数。
- 报名审核:管理员逐条审核考生的报名表单,确认照片、证件信息是否符合要求,通过或驳回都要写审核意见。
- 考点与考场管理:维护考点信息,为每个考试分配考场容量,系统自动控制报名人数不能超过容量上限。
- 成绩管理:考试结束后导入或录入成绩,支持批量录入和单个修改。
- 数据统计:按考试类型、时间维度查看报名人数、审核通过率、缴费金额等统计数据。
两条业务线之间通过考试计划这个核心实体连接起来。考生报名依赖于管理员创建的考试计划,成绩又挂在具体的考试计划下面。设计的时候把这种关联关系理顺了,后面的表结构和接口定义就顺理成章了。
2.2 核心数据表设计
数据库是报名系统的地基,表结构设计合理,业务逻辑写起来就顺手。这套系统里我设计了七张核心表,下面把最关键的几张拿出来说说。
用户表(user):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| phone | varchar(20) | 登录手机号,唯一索引 |
| password | varchar(100) | BCrypt加密后的密码 |
| role | tinyint | 角色:0管理员,1考生 |
| status | tinyint | 状态:0禁用,1正常 |
| create_time | datetime | 创建时间 |
考试计划表(exam_plan):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| exam_name | varchar(100) | 考试名称,如“2025年上半年英语四级” |
| exam_type | varchar(50) | 考试类型编码 |
| register_start_time | datetime | 报名开始时间 |
| register_end_time | datetime | 报名结束时间 |
| exam_time | datetime | 考试时间 |
| fee | decimal(10,2) | 报名费用 |
| total_quota | int | 总报名名额 |
| enrolled_count | int | 已报名人数 |
| status | tinyint | 状态:0草稿,1开放报名,2报名结束,3考试完成 |
报名记录表(registration_record):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 关联用户表 |
| plan_id | bigint | 关联考试计划表 |
| id_card | varchar(20) | 身份证号 |
| real_name | varchar(50) | 真实姓名 |
| photo_url | varchar(255) | 证件照地址 |
| site_id | bigint | 所选考点 |
| status | tinyint | 状态:0待审核,1通过,2驳回,3已缴费,4已取消 |
| audit_comment | varchar(255) | 审核意见 |
| create_time | datetime | 报名时间 |
成绩表(exam_result)和考场表(exam_site)这里就不逐一展开了,源码里有完整的建表SQL。有一点要特别提醒:报名记录表里plan_id和status一定要建联合索引,因为业务上最常见的查询就是“查某个考试下的所有报名记录按状态过滤”,没有索引的话数据量上来以后这条SQL会慢到让你怀疑人生。
2.3 业务上的三个关键设计决策
第一个决策是报名状态机。报名记录的状态流转我设计成一条单向链路:待审核、审核通过、已缴费、已完成,另外还有驳回和取消是两个旁路状态。状态流转全部由后端Service层控制,前端只负责展示,不允许直接改状态字段。这样做的目的是防止有人绕过流程直接调接口把状态改成已缴费。
第二个决策是名额扣减方式。报名人数限制是这类系统的刚需,我的做法是:报名提交时开启事务,先用select加行锁的方式查询当前报名人数,判断没满后再执行insert插入报名记录,同时update考试计划的enrolled_count字段加一。很多人会在这里踩坑,直接先select出来判断再insert,高并发下两个请求同时查到没满,结果名额就超了。
第三个决策是文件存储方案。证件照这类文件我没有走后端接口保存,而是直接传到服务器上的独立目录,Nginx做一个静态资源映射来访问。后端只保存文件URL到数据库。因为图片这类静态文件如果走SpringBoot接口传输,会占用应用服务器带宽,报名高峰期会影响接口响应速度。专业一点的做法是上OSS,但部署教程里为了让大家本地跑起来方便,我用本地存储加Nginx映射实现,后续要换OSS只改一个上传接口就行。
3. 后端实现细节:SpringBoot+MyBatis组合拳
3.1 SpringBoot初始化与核心配置
后端工程我选择SpringBoot 2.7.x版本,很多人问为什么不用3.x。原因很现实:SpringBoot 3.x的javax包迁移到了jakarta,MyBatis的starter也升级了对应版本,本地环境如果JDK没升到17以上,跑起来全是兼容性报错。对大多数做单体系统的团队来说,2.7版本稳定、资料多、遇到问题搜得到答案,完全够用。热搜里有个关键词叫“springboot版本太高”,就是这个问题。
pom.xml里需要引入的核心依赖有这些:
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.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
application.yml文件是后端的心脏,有几个配置项必须写对:
yaml复制server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/exam_system?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.exam.entity
configuration:
map-underscore-to-camel-case: true
pagehelper:
helper-dialect: mysql
reasonable: true
support-methods-arguments: true
mapper-locations指定XML文件位置,这个不配的话MyBatis扫描不到SQL映射。map-underscore-to-camel-case开启驼峰映射,数据库的create_time就能自动映射到实体的createTime字段,省去一堆手动映射代码。
3.2 MyBatis XML映射文件的使用要点
MyBatis的核心玩法就是把SQL写在XML里,和Java代码解耦。分层架构里,Mapper接口只定义方法签名,XML文件里写对应的SQL语句,两者通过namespace和id对应起来。
举一个典型的例子,报名记录的分页条件查询:
xml复制<select id="selectRegistrationPage" resultType="com.exam.entity.RegistrationRecord">
SELECT rr.*, ep.exam_name
FROM registration_record rr
LEFT JOIN exam_plan ep ON rr.plan_id = ep.id
<where>
<if test="planId != null">
AND rr.plan_id = #{planId}
</if>
<if test="status != null">
AND rr.status = #{status}
</if>
<if test="keyword != null and keyword != ''">
AND (rr.real_name LIKE CONCAT('%', #{keyword}, '%')
OR rr.id_card LIKE CONCAT('%', #{keyword}, '%'))
</if>
</where>
ORDER BY rr.create_time DESC
</select>
这里的
还有一点要提醒:MyBatis查询结果映射到实体类时,如果开启了驼峰映射,数据库字段的create_time会自动转成createTime。如果没生效,检查两件事:一是配置文件里map-underscore-to-camel-case是否设置为true,二是实体类的属性命名是否和驼峰规则一致。这个不起眼的配置能省大量时间。
3.3 PageHelper分页插件与统一返回结构
后台管理列表页几乎全部需要分页,考生报名记录、审核列表、成绩列表都是。手动写limit语句再查总数太痛苦了,我直接用PageHelper分页插件,用法极其简单。
Service层的典型写法:
java复制public PageResult<RegistrationRecord> getRegistrationPage(RegistrationQuery query) {
PageHelper.startPage(query.getPageNum(), query.getPageSize());
List<RegistrationRecord> list = registrationMapper.selectRegistrationPage(query);
PageInfo<RegistrationRecord> pageInfo = new PageInfo<>(list);
return PageResult.success(list, pageInfo.getTotal());
}
PageHelper.startPage和Mapper查询要放在紧接着的两行,中间不能混入其他数据库操作,否则分页会失效。分页原理是PageHelper在MyBatis执行SQL前自动拦截,改写SQL拼接limit语句,同时执行一条count查询。合理参数配置里的reasonal: true也很关键,它能防止用户传pageNum=0或者超过总页数时出现空数据。
前端分页组件需要的数据结构,前端传pageNum和pageSize往PageResult里填,返回结果包含list和total两个字段,前端表单一渲染就出列表页。
接口返回的统一结构我设计了这样一套:
java复制public class Result<T> {
private Integer code; // 200成功,4xx和5xx为失败
private String message; // 提示信息
private T data; // 业务数据
}
所有Controller都返回Result对象,而不是直接返回实体类。这样做的好处一是前端处理逻辑统一,只用判断code就能知道请求是否成功;二是异常处理方便,全局异常处理器把业务异常转成对应的code和message,前端拿不到一堆看不懂的栈信息。
3.4 报名接口的完整实现与防并发坑
报名是这套系统里最关键的业务接口,我把这个接口的实现过程完整拆出来讲一遍。
Controller层:
java复制@PostMapping("/register")
public Result<Void> register(@RequestBody RegisterRequest request) {
registrationService.register(request);
return Result.success();
}
Service层核心逻辑:
java复制@Transactional(rollbackFor = Exception.class)
public void register(RegisterRequest request) {
// 1. 校验用户登录态和考试计划状态
ExamPlan plan = examPlanMapper.selectById(request.getPlanId());
if (plan == null || plan.getStatus() != 1) {
throw new BusinessException("考试计划不存在或不在报名期内");
}
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(plan.getRegisterStartTime()) || now.isAfter(plan.getRegisterEndTime())) {
throw new BusinessException("不在报名时间段内");
}
// 2. 防重复报名校验
Integer count = registrationMapper.countByUserAndPlan(request.getUserId(), request.getPlanId());
if (count > 0) {
throw new BusinessException("您已报名该考试,请勿重复提交");
}
// 3. 行锁扣减名额
int updated = examPlanMapper.decreaseQuota(plan.getId());
if (updated == 0) {
throw new BusinessException("报名名额已满");
}
// 4. 插入报名记录
RegistrationRecord record = new RegistrationRecord();
record.setUserId(request.getUserId());
record.setPlanId(request.getPlanId());
// ... 设置其他字段
registrationMapper.insert(record);
}
这里最核心的是第3步。decreaseQuota的SQL是这样的:
xml复制<update id="decreaseQuota">
UPDATE exam_plan
SET enrolled_count = enrolled_count + 1
WHERE id = #{planId}
AND enrolled_count < total_quota
</update>
这一步直接把判断和扣减放在同一个SQL里完成,使用数据库行锁保证并发安全。多个考生同时报名时,数据库层面会串行执行这个update,后执行的会因为不满足enrolled_count < total_quota条件而更新0行,Service层拿到updated=0就抛出名额已满的异常。这个方案比先select判断再update的方式安全得多,也是报名系统中最重要的防超卖设计。
4. 前端工程搭建与联调:Vue实战
4.1 Vue3+Vite工程初始化与路由配置
前端我选择Vue3+Vite的组合,创建工程用官方脚手架:
bash复制npm create vite@latest exam-registration-web -- --template vue
cd exam-registration-web
npm install
npm install vue-router@4 pinia axios element-plus
Vite相比Webpack的启动速度是碾压级别的,开发体验好了不是一点半点。路由配置需要同时考虑考生端和管理端的页面结构:
javascript复制import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
children: [
{ path: '', redirect: '/home' },
{ path: 'home', component: () => import('@/views/Home.vue') },
{ path: 'register', component: () => import('@/views/exam/Register.vue') },
{ path: 'my-registration', component: () => import('@/views/exam/MyRegistration.vue') },
{ path: 'result', component: () => import('@/views/exam/Result.vue') }
]
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAuth: true, role: 'admin' },
children: [
{ path: 'plan', component: () => import('@/views/admin/ExamPlan.vue') },
{ path: 'audit', component: () => import('@/views/admin/Audit.vue') },
{ path: 'result-manage', component: () => import('@/views/admin/ResultManage.vue') }
]
}
]
路由守卫用来做权限控制,这个环节一定要加。如果用户没登录就访问管理端页面,直接跳转登录页;登录了但角色不是管理员,也要拦截掉:
javascript复制router.beforeEach((to, from, next) => {
const [token](https://taotoken.net?utm_source=general) = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else if (to.meta.role === 'admin' && !isAdmin()) {
next('/home')
} else {
next()
}
})
4.2 Axios封装与跨域处理
前端请求后端接口,axios封装是绕不开的一步。我把axios实例单独放在utils/request.js里,统一处理请求头、Token注入和错误拦截:
javascript复制import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器:注入Token
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:统一处理code
request.interceptors.response.use(
response => {
const res = response.data
if (res.code === 200) {
return res
} else {
ElMessage.error(res.message)
return Promise.reject(new Error(res.message))
}
},
error => {
if (error.response) {
switch (error.response.status) {
case 401:
localStorage.removeItem('token')
router.push('/login')
break
case 403:
ElMessage.error('没有权限访问')
break
case 500:
ElMessage.error('服务器内部错误')
break
}
}
return Promise.reject(error)
}
)
baseURL这里我写的是/api,这是配合Vite开发代理和Nginx反向代理用的地址。开发环境跨域怎么解决?Vite的proxy配置:
javascript复制// vite.config.js
export default defineConfig({
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
这样开发时前端请求/api/xxx时,Vite会自动把请求转发到后端8080端口,浏览器端没有跨域问题。生产环境部署时,Nginx也要做同样的事情,把/api前缀的请求代理到后端服务。很多新手直接把前端请求地址写成http://localhost:8080,开发时开跨域配置,生产部署时又改一遍代码,这种做法很不优雅,统一用/api前缀加代理才是标准方案。
4.3 报名、查询、审核三个典型页面实现思路
前端页面看起来多,但核心交互模式就那几种,我这里讲三个有代表性的。
报名页面是最复杂的表单场景。考生选择考试计划、填写个人信息、上传证件照、选择考点,最后提交。表单校验用Element Plus的Form组件,身份证号用正则校验格式,照片上传用Element Plus的Upload组件提交到上传接口。提交成功后跳转到我的报名页面查看状态。这里要注意的点是:提交按钮要加loading状态和防重复点击,不然考生手快了会连续提交好几条报名请求,即使后端做了防重,前端也要做好这层保障。
我的报名列表页是典型的表格加状态标签场景。页面加载时调分页查询接口,把报名记录渲染到表格里,状态列用tag显示不同颜色,待审核是黄色,通过是绿色,驳回是红色。点击详情可以查看审核意见和报名基本信息。这个页面最体现前后端分离的优势:后端只用返回JSON,前端怎么展示、什么颜色、什么交互完全自己控制,要改样式不用动后端一行代码。
审核页面是管理端最核心的操作页。管理员在一个列表里看到所有待审核记录,每条记录右侧有通过和驳回两个操作按钮。点击通过调审核接口,后端返回成功后,当前行从列表移除或者刷新状态。审核操作非常高频,所以要做得顺手。我额外加了一个审核意见输入框,驳回时必须填写原因,这个信息考生端能看到,避免审核标准不透明引发的投诉。
5. 从开发到部署:完整部署教程
5.1 部署环境准备清单
部署这套系统前,先把环境准备好。我这次以一台干净的Linux服务器为例,操作系统用的是CentOS 7.9,配置清单如下:
| 软件 | 版本要求 | 安装方式 |
|---|---|---|
| JDK | 1.8+(推荐1.8或11) | yum install java-1.8.0-openjdk |
| MySQL | 8.0+(5.7也可) | rpm包或docker |
| Nginx | 1.20+ | yum install nginx |
| Node.js | 16+(仅构建时需要) | 本地环境构建即可 |
| Maven | 3.6+ | 本地或服务器均可 |
需要说明的是,前端构建不一定非要在服务器上做,完全可以本地构建好生成dist目录后,把dist文件夹上传到服务器。这样服务器只需要JDK、MySQL、Nginx三个运行环境,少装一堆东西。
5.2 后端打包与启动的完整流程
后端采用标准的Maven打包方式。在项目根目录执行:
bash复制mvn clean package -DskipTests
打包完成后,target目录下会生成exam-registration-server-1.0.0.jar。这个jar包包含了所有依赖和内嵌的Tomcat,直接拷贝到服务器任何目录都能运行。
启动之前先确认数据库已经建好。把源码里的db.sql导入MySQL:
bash复制mysql -uroot -p < db.sql
然后启动jar包:
bash复制nohup java -jar exam-registration-server-1.0.0.jar --spring.profiles.active=prod > app.log 2>&1 &
这里我用nohup把进程放到后台运行,日志输出到app.log。验证是否启动成功:
bash复制curl http://localhost:8080/api/health
返回健康检查结果就说明后端起来了。这里强烈建议在项目里加一个健康检查接口,部署的时候调试会快很多。
5.3 前端构建与Nginx反向代理配置
前端构建在本地执行:
bash复制npm run build
构建完成后,dist目录就是最终的静态资源。把这个目录上传到服务器的/usr/share/nginx/html/exam目录下。
然后配置Nginx。关键有两个作用:一是托管前端静态资源,二是把/api开头的请求反向代理到后端8080端口。
nginx复制server {
listen 80;
server_name your-domain.com;
root /usr/share/nginx/html/exam;
index index.html;
# 前端路由history模式:所有非文件请求都转给index.html
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;
}
# 证件照等静态文件访问
location /upload/ {
alias /data/exam/upload/;
}
}
注意try_files这行配置,这是前端路由使用history模式时必须要加的。不加的话,用户点击浏览器刷新按钮访问比如/admin/plan这个地址,Nginx会去找服务器上对应的物理文件,找不到就404了。加了try_files之后,所有不存在的路径都重新指向index.html,Vue Router接管路由后就能正确渲染对应页面。关于证件照,后端上传的文件保存到/data/exam/upload目录,Nginx做一个alias映射,前端上传的图片路径可以直接通过http://域名/upload/xxx.jpg访问到。
配置好后重载Nginx:
bash复制nginx -t
nginx -s reload
前端和后端都启动后,访问http://your-domain.com就能看到系统首页。整个部署链路就是:浏览器访问Nginx获取静态页面,页面里的接口请求发给Nginx的/api路径,Nginx转发给后端的SpringBoot服务,后端再读写MySQL数据库。
5.4 Windows本地跑通的简化方案
很多初学者没有Linux服务器,在我本地Windows环境也能跑通整套流程。后端直接用IDEA运行SpringBoot主类,端口8080。前端在命令行执行npm run dev,Vite开发服务器默认跑在5173端口。数据库用本机安装的MySQL或直接上XAMPP里的MySQL都行。
唯一要动的地方是数据库连接信息。如果本地MySQL的密码不一样,改application.yml里的spring.datasource.password即可。Vite的proxy配置我已经写好了,开发模式下前端请求会自动转发到8080,跨域问题在开发阶段就被代理机制解决了。Windows下跑通开发环境,再把前端build出dist,用Nginx部署,整套流程就熟悉了。
6. 常见问题与排查实录
6.1 前端跨域问题的四种表现和定位方法
跨域问题是前后端分离开发中遇到概率最高的问题。我总结了几种常见表现和对应的定位方法。
一种是浏览器控制台报CORS错误。这种情况通常是后端没配置跨域,或者前端请求地址直接写了另一个端口的完整URL。解决方法是开发环境用Vite的proxy,不直接跨域;或者后端加一个全局CORS配置类:
java复制@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);
}
}
另一种是请求能发出但带不上Cookie或者Header鉴权失败。这种情况多半是跨域配置里allowCredentials和allowedHeaders设置不对。前端axios设置了Authorization请求头,后端allowedHeaders必须包含Authorization才行。
第三种是开发者工具里看到请求状态码是200,但浏览器的Network里显示为红色或者被blocked。这其实是预检请求OPTIONS没通过,需要后端对OPTIONS请求也放行,或者直接返回204。
第四种坑是开发时一切正常,部署到Nginx后反而接口全挂了。原因是部署后前端页面和接口都通过Nginx同域访问,本来不需要跨域了,但如果代理配置不对,接口请求转发不到后端,就会表现为401或者502。排查思路是先curl一下后端的/api地址,确认后端通了,再排查Nginx的proxy_pass配置。
6.2 MyBatis分页插件失效的原因排查
PageHelper分页失效是我见过最多的问题之一。典型现象是设置了startPage但没有生效,查出来还是全部数据,或者分页SQL执行了两遍。
排查思路按顺序来:先确认PageHelper版本和Spring Boot版本是否兼容。我遇到过SpringBoot 2.7配了老版本pagehelper-spring-boot-starter导致完全不生效的情况,升级到1.4.7就好了。
再确认startPage的位置。PageHelper的原理是拦截下一次执行的SQL,如果startPage和Mapper查询之间隔了其他数据库操作,或者隔了一层方法调用,拦截就可能失效。正确的写法是startPage紧挨着Mapper调用,中间不要有任何数据库操作。如果Service层方法上加了@Transactional,也要小心,有时事务代理的机制会影响PageHelper的拦截生效时机。
最后检查SQL本身。如果查询SQL里已经手动写了limit,分页插件会再拼一个limit,结果看起来就像“分页失效”但其实是一堆SQL拼错了。排查时打开MyBatis的SQL日志:
yaml复制logging:
level:
com.exam.mapper: debug
日志里能看到MyBatis执行的完整SQL,一眼就能分辨是分页没拦截还是SQL拼接出了问题。
6.3 前端刷新404与打包后静态资源404
这两个问题都集中在前端构建和Nginx配置上。
刷新404,也就是用户访问某个路由后刷新页面,结果页面打不开。原因我在5.3里提过了,Vue Router使用history模式时,依赖浏览器的history API,但如果Nginx没有配置try_files,服务器收到/admin/plan这个请求后会去找对应的物理路径,找不到就返回404。解决方法是Nginx配置try_files $uri $uri/ /index.html;。如果用的不是Nginx,Tomcat部署静态资源也需要类似的后退配置。
打包后静态资源404是另一个常见问题,表现形式是dist目录部署后,页面上引用的JS和CSS文件找不到。原因通常是Vite构建时默认的base路径是/,如果你的前端部署在二级目录,比如http://server/exam/,资源路径就变成了/exam/assets/xxx,但页面请求的是/assets/xxx,就404了。解决方式是构建时指定base:
javascript复制// vite.config.js
export default defineConfig({
base: '/exam/'
})
这个配置要跟前端实际部署的路径保持一致,构建后所有资源路径都会自动加上前缀。
6.4 MySQL 8连接报错与数据导入问题
MySQL 8.0的用户认证方式和5.x完全不同,默认使用caching_sha2_password而不是mysql_native_password。老版本的驱动或者代码连接时会报认证失败。解决办法有两个:一是用最新版的mysql-connector-java 8.0.x驱动,我在pom里已经用了8.0.33,没问题;二是如果必须要用老驱动,把用户改回旧认证方式:
sql复制ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '123456';
FLUSH PRIVILEGES;
连接URL里还容易踩一个时区问题,报错信息类似Server returns invalid timezone. Need to set 'serverTimezone' property。因为在MySQL连接URL里加上serverTimezone=Asia/Shanghai就能解决,我在application.yml里已经写好了。
导入db.sql备份数据时,如果遇到文件太大或者编码问题,推荐用命令行导入而不是Navicat的可视化操作。命令行导入时指定utf8字符集:
bash复制mysql -uroot -p --default-character-set=utf8 < db.sql
这样可以避免中文字符乱码。
6.5 SpringBoot版本过高的兼容性问题
前面提到了SpringBoot 3.x的兼容性坑,这里再多说几句。SpringBoot 3.0以后,底层从javax迁移到了jakarta命名空间,这意味着两个大变化。
第一个变化是很多依赖库必须升到对应版本,比如MyBatis的spring boot starter要升级到3.x版本。如果你的项目用了旧版的mybatis-spring-boot-starter,在SpringBoot 3.x下会直接报ClassNotFoundException,找不到javax.servlet这类类。
第二个变化是代码里的导入语句要改,比如javax.servlet.http.HttpServletRequest要改成jakarta.servlet.http.HttpServletRequest。很多旧教程里的代码都是javax开头,照着敲在SpringBoot 3下就编译不过去。
所以在技术选型时我明确锁定SpringBoot 2.7.x,这是目前最稳妥的组合。如果确实要用SpringBoot 3,那就必须同步升级所有相关依赖,并且确认第三方库都发布了适配版本。这几个坑我自己都踩过一遍,写出来就是希望大家不要重复走弯路。
最后说几句实在话
这套语言考试信息报名系统从设计到编码,再到完整部署,整条链路都属于前后端分离项目里比较标准的形态。如果你正在学习SpringBoot和Vue的整合开发,或者毕业设计、公司内部系统需要一个报名类业务,这套源码可以直接抄作业。我在实际部署中最深的体会是:一个项目能不能顺利跑起来,往往不是看代码写得多高级,而是基础配置是否扎实,数据库连接、分页插件、跨域配置、Nginx代理这四件事做对了,系统就成功了大半。源码里的数据库脚本和部署文档我都放好了,照着跑一遍,再回头改自己的业务逻辑,理解会深很多。
