先说结论:这套"前后端分离招生宣传管理系统",本质就是一个典型的校园招生业务闭环——专业展示、计划发布、在线报名、后台审核、数据统计。技术栈是SpringBoot + Vue + MyBatis + MySQL,前端用Vue全家桶,后端提供RESTful接口,数据库用MySQL存业务数据。适合正在做毕业设计、刚入职想练手前后端分离项目、或者学校/培训机构想快速搭一套招生系统的朋友参考。源码结构和部署流程都比较规整,照着跑一遍能把前后端分离开发的完整链路摸清楚。
这套项目我实际部署跑过,代码层面的东西不算复杂,真正的价值在于它的"业务完整性"和"部署可复现性"。很多新手学完框架基础后,拿着手里的项目不知道怎么串联起来,要么卡在跨域,要么卡在Token,要么卡在数据库连不上。这篇文章我就从设计思路、数据库、后端、前端、部署、踩坑这几个维度完整拆一遍,尽量讲清楚每一步为什么要这么做,而不是只给你甩几个命令。
1. 项目概述与整体设计思路
1.1 招生宣传系统到底在解决什么问题
招生宣传管理系统这个名字听起来很宽泛,但落到实际业务里,核心其实是三个字:信息流。考生和家长需要查学校有哪些专业、每个专业招多少人、学费多少、学制几年;招生老师需要发布招生计划、更新宣传文章、维护专业信息;管理员需要审核报名记录、统计报名数据。这三类角色之间的信息流转,就是整个系统要解决的核心问题。
从系统功能模块来看,通常包含这么几个部分:专业信息管理、招生计划管理、宣传文章发布、在线报名登记、报名审核与统计、用户权限管理。前台面向考生和家长,提供查询和报名功能;后台面向管理员和招生老师,提供服务端数据管理和审核功能。业务闭环很清晰,非常适合用来学习和二次开发。
1.2 为什么选择前后端分离 + SpringBoot + Vue这套组合
先说前后端分离。传统开发模式里,页面和后端逻辑混在一起,JSP或者Thymeleaf模板写完,改个前端样式经常要重启后端服务,而且前端开发和后端开发没法并行推进。前后端分离之后,前端只负责页面渲染和交互,后端只提供JSON数据的接口,两边通过HTTP协议通信,开发效率高不说,后期前端可以单独部署到Nginx,后端单独部署到服务器,扩展性和维护性都强很多。
再说技术栈选型。SpringBoot解决了Spring框架繁琐的XML配置问题,内置Tomcat,一个jar包就能跑起来,是目前Java后端开发的事实标准。Vue作为渐进式JavaScript框架,上手曲线平滑,组件化开发让页面复用变得非常方便,配合Vue Router和Vuex/Pinia可以很好地管理路由和状态。MyBatis是持久层框架,SQL由开发者自己控制,在复杂查询和SQL调优场景下比JPA更灵活,面试中也经常被问到。MySQL不用多说,开源、稳定、生态成熟,中小型系统的首选数据库。
这套组合的优势在于:每一层都有清晰边界,任何一个环节出问题都能快速定位;而且这套技术栈在招聘市场上的需求量大,学完直接能用在工作中。用这套技术栈来做招生管理系统,属于"杀鸡用牛刀",但正因为如此,才能把各个技术点的最佳实践都覆盖到。
2. 数据库设计与SQL脚本准备
2.1 核心表结构设计思路
数据库设计是整个系统的地基,地基打不好,后面写接口、写页面全都会别扭。招生宣传系统的数据库表设计,核心围绕"用户-专业-计划-报名-文章"这几个业务实体展开。
我建的时候分了这几张表:用户表(sys_user)、专业信息表(major_info)、招生计划表(recruit_plan)、宣传文章表(article_info)、报名记录表(enroll_record)、公告表(notice_info)。建议再加一张角色表或者直接通过用户表的role字段来区分权限,小规模系统用字段就够了,省得表关联太复杂。
核心表关系是:专业信息和招生计划是多对一的关系,一个专业在一段时间内可能对应多条不同年份的招生计划;报名记录关联专业,同时记录学生的基本信息;用户表和报名记录之间通过操作人字段关联,但不建强外键约束,保持表间的松耦合。所有表都建议加上create_time、update_time两个字段,方便排查问题和做排序。
下面给出最核心的两张表SQL,其他表结构在完整源码里都有,这里先看设计思路:
-- 专业信息表 CREATE TABLE `major_info` ( `id` int NOT NULL AUTO_INCREMENT, `major_name` varchar(100) NOT NULL COMMENT '专业名称', `department` varchar(100) DEFAULT NULL COMMENT '所属院系', `education_level` varchar(20) DEFAULT '本科' COMMENT '学历层次', `duration` varchar(20) DEFAULT '四年' COMMENT '学制', `tuition` decimal(10,2) DEFAULT NULL COMMENT '学费', `description` text COMMENT '专业介绍', `cover_image` varchar(255) DEFAULT NULL COMMENT '封面图', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='专业信息表'; -- 报名记录表 CREATE TABLE `enroll_record` ( `id` int NOT NULL AUTO_INCREMENT, `student_name` varchar(50) NOT NULL COMMENT '学生姓名', `id_card` varchar(18) DEFAULT NULL COMMENT '身份证号', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `major_id` int NOT NULL COMMENT '意向专业ID', `score` decimal(5,2) DEFAULT NULL COMMENT '高考分数', `status` tinyint DEFAULT '0' COMMENT '审核状态 0待审核 1已通过 2已拒绝', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_major` (`major_id`), KEY `idx_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报名记录表';2.2 SQL脚本的导入与初始化
源码包里一般会带一个sql文件夹,里面有两类脚本:一类是建表脚本schema.sql,一类是初始化数据脚本data.sql。建议分两个文件,不要混在一起,方便后期排查。初始化数据里至少要包含一个管理员账号,用户名admin,密码经过BCrypt加密后存储,这里注意密码一定不能明文存在数据库里。
导入数据库用命令行或者Navicat都行。命令行操作是:
mysql -u root -p create database recruit_system default character set utf8mb4; use recruit_system; source /path/to/schema.sql; source /path/to/data.sql;utf8mb4这个字符集一定不能省,不然存emoji表情或者生僻字会出现乱码。导入完成后可以随机抽几张表看下数据,确认没问题再改后端配置。
3. 后端SpringBoot核心实现与业务逻辑
3.1 后端工程目录结构
后端工程用的是标准Maven结构,包名一般叫com.xxx.recruit。初始搭建的时候,我建议按功能模块分包,而不是按技术类型分包,这样业务边界更清晰。推荐这样的结构:
com.xxx.recruit ├── common # 通用工具类、统一返回结果、全局异常处理 │ ├── Result.java │ ├── ResultCode.java │ └── GlobalExceptionHandler.java ├── config # 配置类,如跨域配置、拦截器注册 ├── controller # 控制层,接收请求并返回结果 ├── service # 业务层,接口+实现类 ├── mapper # MyBatis的Mapper接口 ├── entity # 实体类 ├── interceptor # 登录拦截器或JWT拦截器 ├── utils # JwtUtil、MD5等工具类 └── RecruitApplication.javaController层只负责参数接收和结果封装,具体逻辑全部下沉到Service层,Mapper层只做数据库交互,这种分层方式刚开始写会感觉繁琐,但项目一大了就能体会到好处——每个层次职责单一,出问题不用翻遍所有代码。
3.2 SpringBoot整合MyBatis时的配置要点
SpringBoot整合MyBatis的配置集中体现在application.yml文件里。很多新手在这里容易踩坑,我直接把关键配置列出来:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/recruit_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.xxx.recruit.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl驱动类这里要特别强调,MySQL 8.x必须用com.mysql.cj.jdbc.Driver,老版本的com.mysql.jdbc.Driver已经废弃了。URL里serverTimezone=Asia/Shanghai解决时区问题,useSSL=false解决连接警告。map-underscore-to-camel-case这个配置非常实用,它能自动把数据库的下划线字段映射为Java的驼峰属性,比如数据库里的major_name会自动匹配实体类里的majorName,免去在XML里写大量resultMap。
日志配置里log-impl用StdOutImpl,开发阶段能在控制台直接看到SQL语句,排查问题非常方便,上线前再关掉。
3.3 登录认证与Token机制设计
前后端分离项目里,最常问到的就是Token怎么处理。这套系统我用的方案是:登录成功后后端生成一个JWT(JSON Web Token)返回给前端,前端保存在本地存储中,之后每次请求都在请求头Authorization字段携带这个Token,后端通过拦截器统一校验。
JWT的生成代码核心逻辑如下:
public String generateToken(Integer userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }这个Token设置了一天过期时间,实际项目中可以根据需求调整,管理后台建议短一点,前台用户长一点。登录接口在用户校验通过后返回这个Token,同时返回用户基本信息供前端展示。
后端拦截器的作用是拦截需要登录才能访问的接口,校验请求头里的Token是否有效,无效或者过期直接返回401状态码。拦截后把解析出来的用户信息放入ThreadLocal或请求域中,方便业务层获取当前登录用户。我这里强烈建议写一个自定义注解比如@LoginRequired,在Controller方法上声明需要的权限级别,比在拦截器里硬编码URL列表要灵活得多。
3.4 统一返回结果与全局异常处理
前后端分离开发中,前端最怕的就是后端返回的数据格式不统一。有的接口直接返回一个对象,有的返回List,有的返回Map,前端就要写一堆奇怪的判断逻辑。我通常是定义一个通用返回类Result:
public class Result<T> { private Integer code; // 状态码,200成功,401未登录,500服务器错误 private String message; // 提示信息 private T data; // 返回数据 // 静态方法 success(data), error(message), unauthorized() ... }所有接口的返回值都包裹在Result里,前端只需要根据code判断请求是否成功,然后从data里取数据即可。全局异常处理器用@RestControllerAdvice统一捕获业务异常和系统异常,避免把堆栈信息直接抛给前端,让接口返回的错误信息既安全又友好。
这套代码写起来很机械,但价值极高,能省掉前后端联调时大量扯皮。团队里如果有规范,这个类通常也是最早定下来不动的。
4. 前端Vue项目实现与Token请求链路
4.1 前端工程初始化与环境准备
前端工程用Vue CLI或者Vite创建脚手架,推荐Vite配合Vue 3,启动速度快。但在很多学校的教学环境和企业现有项目中,Vue 2的生态存量依然很大,所以源码里无论用哪种,原理都一样。
初始化命令:
npm create vite@latest recruit-web -- --template vue cd recruit-web npm installnpm install这个步骤经常有人栽跟头。如果网络条件不好,直接设置淘宝镜像源:
npm config set registry https://registry.npmmirror.com装完依赖后,我会顺手安装项目需要的核心依赖:
npm install vue-router@4 axios element-plus piniaElement Plus是Vue 3的UI组件库,用它可以快速搭出表格、表单、弹窗等后台管理界面。前端项目的目录结构我会在src下分成views、components、router、store、api、utils几个目录。views按页面拆分,components放复用组件,api目录按业务模块封装请求方法,utils放axios实例和工具函数。
4.2 Axios请求封装与Token注入的实现
这一步是整个前后端分离项目的灵魂所在。Axios如果每次请求都手写,代码会非常冗余,而且Token注入逻辑很难统一。我习惯单独建一个utils/request.js文件:
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 }, error => { return Promise.reject(error) }) // 响应拦截器:统一处理返回结果 request.interceptors.response.use(response => { const res = response.data if (res.code === 200) { return res.data } else if (res.code === 401) { localStorage.removeItem('token') router.push('/login') ElMessage.error('登录状态已过期,请重新登录') return Promise.reject(new Error('未授权')) } else { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) }) export default request请求拦截器里做的事情就是给每次请求加上Authorization头,响应拦截器里根据状态码做统一处理。这里最关键的是401处理逻辑——Token过期后自动清理本地存储并跳转登录页,这个体验比用户点半天才发现接口全报错要友好得多。
注意baseURL直接写成/api,开发环境通过Vite代理转发到后端,生产环境通过Nginx反向代理转发,这样前端代码不用区分环境,部署的时候非常省心。
4.3 路由与页面访问权限控制
Vue Router的配置比较套路化,但路由守卫是实现权限控制的关键所在。我的做法是:路由表里给每个需要登录才能访问的页面加上meta属性,标记requiredAuth为true,然后在全局前置守卫里做校验:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiredAuth && !token) { next('/login') } else if (to.path === '/login' && token) { next('/') } else { next() } })如果系统里有面向普通用户和管理员的不同页面,那就需要更进一步,从后端返回的用户信息里解析角色,在路由守卫里判断当前用户的角色是否有权限访问目标路由,这样就能做到不同角色看到不同菜单、访问不同页面。
4.4 核心页面与表单联调的实现细节
前端页面里,最核心的几个页面是专业列表、招生计划展示、在线报名表单和后台管理表格。专业列表页就是调用后端接口获取专业数据,用Element Plus的表格或者卡片组件渲染;在线报名表单用el-form做好字段校验,电话号码格式、身份证号格式都需要在前端先拦一道,减轻后端压力。
后台管理表格用el-table展示报名记录,每一行提供审核通过和拒绝按钮,操作成功后刷新列表数据。这里有个细节可以分享:表格分页我用的后端分页方案,前端传pageNum和pageSize参数,后端返回总条数和当前页数据,而不是一次性把几万条数据全查出来丢给前端,数据量一大前端浏览器就会卡死。
页面交互层面的核心逻辑都不难,难的是接口字段名对不上、数据类型对不上这类问题。所以我会在组件里先看清楚后端返回的数据结构,再决定怎么渲染,这就是联调的日常。
5. 从本地到服务器:完整部署教程
5.1 本地部署:十分钟跑通前后端
本地部署前,确认电脑上已经装好JDK 1.8+、Maven 3.6+、Node.js 14+、MySQL 8.0。环境检查命令:
java -version mvn -version node -v npm -v后端启动步骤是:先把数据库脚本导入MySQL,然后修改application.yml中的数据配置,最后在项目根目录执行:
mvn clean package -DskipTests java -jar target/recruit-system-1.0.0.jar看到Spring Boot的启动日志,显示Tomcat started on port(s): 8080就说明后端已经跑起来了。前端启动:
cd recruit-web npm install npm run devVite默认端口是5173,但我们在axios请求里写了baseURL是/api,所以需要在vite.config.js里加服务代理:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }配置完成后,浏览器访问http://localhost:5173,登录页面能正常跳转、首页能拉取到接口数据,说明本地链路已经全部打通。
5.2 服务器部署:Nginx前后端分离部署
服务器部署我推荐用云服务器,系统选Linux的CentOS(也可以用别的发行版,命令略有差异)。整体流程分四步:
第一步:安装JDK、MySQL、Nginx。JDK用yum安装OpenJDK,MySQL如果yum源版本不够就直接下载官方仓库安装,Nginx用yum install nginx。
第二步:导入数据库脚本,创建同名的数据库和用户,修改后端配置文件里的数据库地址为服务器地址,重新执行mvn clean package打包,把jar包上传到服务器,用nohup后台启动:
nohup java -jar recruit-system-1.0.0.jar --spring.profiles.active=prod > run.log 2>&1 &这里建议分环境配置application-dev.yml和application-prod.yml,避免本地和服务器配置来回改。
第三步:前端构建产物生成,上传dist目录到服务器指定目录。前端打包命令:
npm run build生成的dist文件夹里就是纯静态文件,上传到/srv/www/recruit-web下。
第四步:配置Nginx。Nginx一方面托管前端静态文件,另一方面把/api开头的请求反向代理到后端端口。核心配置如下:
server { listen 80; server_name your_domain_or_ip; # 前端静态资源 location / { root /srv/www/recruit-web; 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files配置非常关键,它的作用是当用户访问某个路由路径时,如果静态目录里找不到对应文件,就返回index.html,由前端路由接管页面渲染,这样Vue Router的history模式才能正常工作。配置完成后重启Nginx:
nginx -t systemctl restart nginx浏览器直接访问服务器IP,能看到系统页面并且接口数据正常加载,整个部署就算完成了。
5.3 部署过程中的参数选择说明
部署中有几个参数值得说清楚。后端启动时加了--spring.profiles.active=prod,这个参数作用是让SpringBoot加载application-prod.yml文件中的配置,隔离各环境配置。Nginx里的proxy_pass我写的http://127.0.0.1:8080,注意结尾有没有/的区别很细微,建议以实际请求路径为准做测试。数据库连接池里的max-active参数建议保持默认,招生系统的并发量不会太高,不需要过度调优。
6. 常见问题与排查技巧实录
6.1 问题速查表
我在部署这套系统的过程中,以及在帮别人排查问题时,遇到的所有高频问题基本都能归到下面这张表里:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 后端启动报端口被占用 | 8080被其他程序占用 | lsof -i:8080找到进程并kill,或修改server.port |
| 前端请求接口报404 | 代理配置错误或后端没启动 | 检查vite.config.js代理配置,确认后端能访问 |
| 登录成功后调用业务接口仍401 | Token没传或拦截器校验失败 | 打开浏览器开发者工具看请求头,确认Authorization有值 |
| 数据库中文乱码 | 字符集不是utf8mb4 | 建库时指定default character set utf8mb4 |
| MyBatis提示Invalid bound statement | Mapper接口和XML映射文件未对应 | 检查mapper-locations路径和XML的namespace |
| npm install非常慢 | 默认源访问慢 | 切换到npmmirror镜像源 |
| 前端打包后刷新404 | 路由模式使用了history | Nginx配置try_files回退到index.html |
| 接口返回时间字段格式不对 | 时区问题或Jackson配置不对 | URL加serverTimezone=Asia/Shanghai,配置Jackson日期格式 |
6.2 两个典型的坑详细说
第一个坑是跨域问题。本地开发时通过Vite代理请求后端,所以看不到跨域报错;但服务器上如果前端静态文件由Nginx管理,接口请求方向是前端页面所在域名或IP发起的,如果没有走代理直接请求后端地址,就会触发浏览器的跨域限制。解决方案是后端在Config类里配置CORS允许跨域,或者在Nginx层完成反向代理,推荐后者,生产环境不暴露后端端口更安全。
第二个坑是MyBatis的mapper-locations配置。很多系统报错Invalid bound statement的原因是application.yml里没有配mapper-locations,或者XML文件没放在classpath对应目录。这个问题的排查思路是:启动日志里看MyBatis的Mapper扫描日志,确认每个接口是否都找到了对应的XML声明;没找到就重点检查文件名、namespace、方法id这三处是否完全一致。
6.3 用这套源码做二次开发怎么最快上手
源码拿到手先不要急着跑,先花半小时把目录结构和表结构过一遍。我推荐的阅读顺序是:先跑通项目,然后用一个最简单的功能,比如专业列表,从前端页面点击开始,沿着路由到api请求,再到controller、service、mapper、SQL,完整走一遍请求链路,你就知道每个文件是干什么的了。之后再想改哪里,直接对应调整就行。
如果要做二次开发,优先考虑这几个扩展点:一是增加图片上传功能,把专业封面和宣传文章的图片上传到服务器;二是增加数据导出功能,把报名信息导出为Excel;三是加一个前端首页的轮播图管理。这三个功能都比较独立,不破坏现有代码结构,适合练手。
写在最后的一些经验体会
这套系统跑通之后,我最大的感受是:前后端分离项目的难点不在写代码,而在理解数据和请求是怎么流动的。从数据库的一张表,到后端接口的一次查询,再到前端页面的一个表格,这条链路理清楚了,整个项目的骨架就掌握了。招生宣传管理系统算是一个很好的练手项目——业务不复杂,但覆盖了登录鉴权、CRUD、分页、部署上线这些实际工作中天天要用的东西。如果你正在学SpringBoot和Vue,别纠结于看多少教程,把源码跑起来,改两个功能,比看十个小时的视频都管用。最后说一个小建议:源码里的配置文件、注释、命名规范都是可以学的地方,但更值得学习的是包结构和请求链路的组织方式,这些才是真正能迁移到其他项目里的东西。