
这段时间我在做一个高校招生宣传管理系统技术栈就是标题里那套SpringBoot Vue MyBatis MySQL。做之前我以为这只是个普通的CRUD项目真正动手才发现招生宣传这个业务场景远比想象中复杂——它既要管内容发布又要管生源数据还得兼顾前后端分离下的权限控制。这套系统做完之后我把整个设计思路、表结构、踩坑记录都整理了一遍今天一次性分享出来。适合正在做毕业设计、课设或者刚入门Java全栈想找个完整项目练手的朋友参考。整个系统我的定位是“内容管理 线索管理 数据统计”三合一。前端用Vue做单页应用后端SpringBoot提供接口MyBatis负责数据库操作MySQL存数据。下面我按项目开发的实际顺序把每个环节的思考和技术细节拆开讲。1. 项目整体设计与技术选型思路1.1 招生宣传系统到底要管哪些东西刚开始搭项目的时候我先把业务场景理清楚了。所谓的招生宣传管理系统本质上解决的是三个问题第一招生信息怎么高效发出去第二意向生源怎么收集和跟进第三宣传效果怎么量化评估。围绕这三个问题系统分成了几个核心模块。信息发布模块负责招生简章、专业介绍、校园新闻、常见问答这些内容的维护后台编辑发布前台考生和家长能看到。报名咨询模块给访客提供在线报名和留言咨询的入口提交后数据进入管理后台。生源管理模块是招生老师的日常工作台可以查看、筛选、跟进意向学生记录电话沟通情况。统计报表模块按专业、地区、时间维度统计报名数据给招生策略做参考。最后还有系统管理模块管理用户账号、角色权限和操作日志。我的建议是不要一上来就埋头写代码先把这些模块在纸上画出来确定好角色边界。这个系统的用户角色我设计了三种超级管理员、招生老师、访客。访客只在前台留资、看内容招生老师管生源和自己的跟进记录超级管理员拥有全部权限。这个权限模型贯穿了后续所有接口设计早定早省事。1.2 为什么选择SpringBoot Vue MyBatis MySQL这个组合这套技术栈可以说是国内中小型管理系统最主流的搭配了选它不是因为追新而是综合考虑了开发效率、学习资料、部署成本和招聘市场需求。后端用SpringBoot理由很直接它把Spring繁琐的XML配置全部干掉内嵌Tomcat一个jar包就能跑起来非常适合快速交付业务系统。自动配置机制让你只需要关注自己的业务代码而不是花大量时间在环境搭建上。MyBatis和Spring Boot的配合很紧密。相比JPA那种全自动ORMMyBatis的半自动特性在复杂查询场景下特别灵活。招生管理里有大量多表关联、条件动态查询的诉求比如按地区、按专业、按分数段筛选生源用MyBatis的动态SQL写起来非常直观。另外MyBatis对SQL优化的把控力更强系统上线后如果发现慢查询直接改XML里的SQL就行不需要动Java代码。前端选Vue是因为它上手曲线平缓组件化开发思路对中小型项目特别友好。管理员后台和前台展示页可以拆成两个Vue应用也可以用一套代码通过路由区分。我这里用的是后者一套代码里通过路由和权限控制区分管理端和前台。MySQL就不用多说了开源免费、性能稳定、运维资料多。招生宣传系统的数据量级远没有到需要上Oracle或者PostgreSQL的程度MySQL 8.0的窗口函数、JSON类型等功能也足够应对统计需求。存储引擎选InnoDB支持事务线上数据安全有保障。整套组合下来无论你以后是继续往Java后端深入还是转前端这套经验都能复用。2. 核心功能拆解与数据库设计2.1 数据库表结构设计实战数据库设计是整个系统的地基这块我花的时间最多。招生宣传管理系统我一共设计了九张核心表。用户表存管理员和招生老师的账号信息包含用户名、密码BCrypt加密存储、真实姓名、手机号、角色ID等字段。角色表存角色名称和角色标识。菜单权限表管理前端路由和后端接口权限。招生资讯表是内容发布的核心字段有标题、摘要、正文、封面图URL、资讯类型简章/新闻/问答、发布状态、发布时间、浏览量。报名信息表记录前台提交的报名数据包含考生姓名、性别、电话、意向专业、预估分数、生源地区、备注、创建时间。咨询留言表记录访客的留言内容和回复状态。生源跟进表是招生老师的日常工作记录关联报名信息ID记录跟进方式、沟通内容、下次跟进时间、跟进状态。专业表维护学校开设的专业信息包括专业名称、所属院系、招生人数、学制、学费。统计报表这块我直接用的SQL动态统计没有单独建报表表。操作日志表记录管理系统内的关键操作。建表时有个很重要的细节统一使用utf8mb4字符集。因为前台报名和留言可能包含表情符号utf8mb4才能完整存储用utf8会出现乱码。我在初期就遇到过一次这种问题后面会详细讲。2.2 自动建表SpringBoot MyBatis项目启动时自动初始化表结构这里说一下很多新手会问的问题SpringBoot项目在MyBatis环境下怎么实现表不存在时自动建表我用的是秒杀项目或CMS项目里比较常见的方案利用SpringBoot的事件监听机制在ApplicationRunner里检查表是否存在不存在就执行建表SQL。思路是这样的项目启动成功后触发一个自定义的Runner它先查询information_schema数据库里是否存在目标表如果返回结果为空就读取classpath下的schema.sql建表脚本并执行。核心代码大概长这样Component public class TableInitRunner implements ApplicationRunner { Autowired private JdbcTemplate jdbcTemplate; Override public void run(ApplicationArguments args) { String checkSql SELECT COUNT(*) FROM information_schema.tables WHERE table_schema DATABASE() AND table_name t_student_apply; Integer count jdbcTemplate.queryForObject(checkSql, Integer.class); if (count null || count 0) { Resource resource new ClassPathResource(sql/schema.sql); // 读取SQL脚本并按分号拆分执行 } } }这个方案的关键点是检测和初始化要放在同一个事务里否则建表执行一半失败了会产生脏数据。另外拆分SQL语句时要注意存储过程、函数这类脚本里可能包含分号简单按分号切割会出问题。咱们这个项目都是普通建表语句按行读取、过滤注释和空行就可以。如果你用的是Spring Boot 2.5以上的版本在application.yml里配置spring.sql.init.modealways也能实现类似效果但那个方案对脚本格式要求严格而且表已存在时启动会报错不如自定义Runner灵活。还是推荐用Runner这种方式写一次能一直用。2.3 数据库设计时的几个关键原则设计完表之后有几个地方我特别想强调一下。第一个是时间字段的处理。创建时间、更新时间这类字段我统一用DATETIME类型并且在应用层通过MyBatis的自动填充功能写入而不是依赖数据库的CURRENT_TIMESTAMP。原因是应用层控制时间可以保证所有环境的时间逻辑一致后续如果做分库分表或者迁移也不会因为数据库时区设置不同而出问题。第二个是逻辑删除和物理删除的取舍。报名信息、跟进记录属于核心业务数据我用的逻辑删除也就是加一个deleted字段查询时统一过滤。资讯类内容也是逻辑删除。只有日志表的数据可以物理删除因为日志量增长快需要定期清理。第三个是索引设计。报名信息表的phone字段、intention_major字段、create_time字段都要建索引。招生资讯表的type和publish_status字段建联合索引。这些索引在数据量到达十万级别之后效果非常明显。我实测过没有索引的模糊查询耗时800多毫秒加上索引后降到几十毫秒差别巨大。3. 后端实现SpringBoot MyBatis操作MySQL的关键细节3.1 项目骨架搭建与依赖配置创建SpringBoot项目的时候我建议直接去Spring Initializr生成勾选Spring Web、MyBatis Framework、MySQL Driver这几个依赖。生成后pom文件里的核心依赖大概是这样的dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency这里要特别提醒MySQL 8.0的驱动类名和5.x不一样8.0用的是com.mysql.cj.jdbc.Driver5.x用的是com.mysql.jdbc.Driver。如果你用的MySQL 8.0但还复制老项目的驱动配置启动时会直接报ClassNotFoundException。application.yml里数据源这样配置spring: datasource: url: jdbc:mysql://localhost:3306/enroll_system?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.enroll.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl注意MyBatis配置里map-underscore-to-camel-case这个选项它能把数据库字段的下划线命名自动映射为Java实体的驼峰命名。比如数据库里apply_status字段会自动映射到实体的applyStatus属性省掉一大堆ResultMap手工映射。log-impl配置成StdOutImpl后控制台会直接打印SQL语句和参数排查问题非常方便这就是大家常说的MyBatis打印SQL配置。3.2 MyBatis动态SQL的实战使用招生管理系统的查询条件多变这是MyBatis最能发挥价值的地方。比如生源列表页招生老师可能组合筛选地区、意向专业、分数区间、报名时间段用动态SQL来实现是最舒服的方式。举个例子查询报名信息列表过滤条件不固定XML里的核心SQL是这样写的select idselectApplyList resultTypecom.enroll.entity.StudentApply SELECT * FROM t_student_apply where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testintentionMajor ! null and intentionMajor ! AND intention_major #{intentionMajor} /if if testminScore ! null AND estimated_score gt; #{minScore} /if if testregion ! null and region ! AND region #{region} /if if teststartTime ! null and startTime ! AND create_time gt; #{startTime} /if /where ORDER BY create_time DESC /select注意XML里的小于号必须转义成lt;否则XML解析直接报错。大于号最好也转义保持一致性。这个坑我见过不少新手踩特地说一下。where标签会自动处理首行AND的问题——如果第一个条件不成立第二个条件成立where会智能去掉SQL语句前面的AND避免语法错误。这套动态SQL写完多条件组合查询就不用再改SQL了只用封装查询对象传给Mapper接口就行。3.3 关于MyBatis缓存我说点实际的热搜词里看到有人搜MyBatis缓存这块确实值得说道。MyBatis的一级缓存是SqlSession级别的默认开启同一个SqlSession内执行两次相同的查询第二次直接走缓存。但SpringBoot集成MyBatis后每次Mapper操作默认都会创建一个新的SqlSession用完就关闭所以一级缓存基本起不到跨请求的作用不用对它抱太多期望。二级缓存是Mapper级别的需要在XML里配置cache/标签开启多个SqlSession可以共享。但我要提醒一句在真正的业务系统里二级缓存反而容易出问题。因为只要表数据一更新所有关联这个Mapper的缓存都要手动清理如果涉及多表联查缓存的数据可能变得不可控。我在这个项目里干脆没有开启二级缓存因为招生数据的实时性要求很高报名进来了立刻就要能被查到宁可多查一次数据库也不能看到旧数据。系统的QPS不高数据库压力完全扛得住。3.4 登录鉴权与接口安全实现这个系统有登录功能用户管理和数据查看必须做权限控制不能让人随便访问后台接口。我采用的是JWT 拦截器的经典组合没有引入Spring Security这么重的东西因为系统的角色模型很简单面向的也是内部使用场景。用户登录成功后后端用JWT生成一个带用户ID和角色标识的token返回给前端。前端每次请求接口时在请求头里带上Authorization: Bearer token。后端写一个拦截器在请求进入Controller之前校验token是否合法、是否过期然后从token里取出用户信息放进ThreadLocalController里就能直接拿到当前登录人。拦截器里做权限控制时我维护了一个RequireRole注解标注在Controller方法上拦截器校验角色是否匹配。比如生源跟进接口只允许招生老师和管理员访问就在方法上标注RequireRole({ADMIN, TEACHER})。这样即使有人绕过前端直接调接口没有对应角色也进不来。密码存储这块必须用BCrypt加密不要用MD5。MD5已经能被彩虹表轻易碰撞出来BCrypt加盐哈希要安全得多。Spring Security里自带BCryptPasswordEncoder即使不引入整个Spring Security单纯引入spring-security-crypto包也能直接用这个类。4. 前端Vue构建投放页与管理后台4.1 Vue环境配置与项目脚手架Vue部分我用的是Vue 3 Vite的组合。相比Vue 2的WebpackVite的冷启动速度和热更新体验好太多了开发期间保存代码几乎是秒级刷新。前端环境的配置建议按这个顺序来先安装Node.js 16以上版本然后npm配置国内镜像再安装Vite脚手架创建项目。# 检查node版本 node -v # 配置淘宝镜像国内开发必备速度差好几倍 npm config set registry https://registry.npmmirror.com # 创建Vue3项目 npm create vitelatest enroll-front -- --template vue # 安装依赖 cd enroll-front npm install npm install vue-router4 axios pinia element-plusElement Plus是Vue 3对应的UI组件库后台管理页面包括表格、表单、弹窗、分页这些组件它都有现成的直接引入就能用。前台展示页我用的普通CSS加Flex布局没有引入多余的UI框架避免包体过大。4.2 Vue路由配置与路由参数传递实战前端路由是Vue项目的骨架这个系统我采用了嵌套路由的结构。管理后台整体是一个布局组件里面再嵌子路由比如/admin下面是/admin/applyList、/admin/enrollNews、/admin/studentFollow这些。前台页面则是独立的/home、/newsDetail、/apply路由。路由参数的传递这里说细一点因为在列表页跳转到详情页这个场景太常见了。比如招生资讯列表页点一条资讯要跳转到详情页最方便的方式就是通过路径参数传ID路由配置这样写{ path: /newsDetail/:id, name: NewsDetail, component: NewsDetail }跳转的时候用编程式导航router.push({ name: NewsDetail, params: { id: row.id } })在详情页读取参数import { useRoute } from vue-router const route useRoute() const newsId route.params.id这里有个坑用params方式传参时如果直接在地址栏刷新页面参数不会丢因为参数在URL路径里。但如果用query方式传参比如?id123刷新也不会丢不过URL会带问号参数。两种方式我建议重要的、需要被收藏或分享的页面用params路径参数临时筛选条件用query参数。另外还要注意当从详情页返回列表页时列表页的滚动位置和筛选条件可能会丢失这个场景我用keep-alive缓存了列表组件数据量的系统内存占用可以接受。4.3 axios封装与前后端联调前端和后台交互我用axios做统一的请求封装。封装时主要解决三件事基础URL统一配置、token自动携带、统一错误处理。// 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 }) // 响应拦截器统一处理错误码 request.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.data }, error { if (error.response error.response.status 401) { ElMessage.error(登录已过期请重新登录) router.push(/login) } else { ElMessage.error(网络异常请稍后重试) } return Promise.reject(error) } ) export default request前后端联调时最常见的报错就是跨域。跨域的本质是浏览器同源策略的限制——前端跑在5173端口后端跑在8080端口端口不同就算跨域。解决方案有两种开发环境用Vite代理生产环境用Nginx反向代理千万不要在后端直接开放跨域那会有安全问题。Vite的代理配置在vite.config.js里export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/xxx时Vite开发服务器会把请求转发到http://localhost:8080/api/xxx浏览器看到的只是同源请求跨域问题就消失了。5. 部署上线与常见问题排查实录5.1 本地开发环境搭建傻瓜式指南开发这个项目时我花了不少时间在环境搭建上尤其是MySQL。MySQL 8.0的安装和5.x差别挺大我整理了一套比较省心的流程。先装MySQL 8.0。Windows直接去MySQL官网下载MSI安装包安装过程中会要求设置root密码选择Use Legacy Authentication还是Use Strong Password Encryption时我建议选第一个。因为很多老项目的连接驱动和工具不支持新的caching_sha2_password认证插件选了后面会连接报错。如果你安装时已经选了第二个可以在命令行里执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码;改回旧认证方式。Java环境装JDK 8或者JDK 17都可以。Spring Boot 2.7和3.x对JDK版本要求不同你如果创建的是Spring Boot 2.x项目用JDK 8就行如果用了Spring Boot 3.x必须JDK 17以上。热词里那个“springboot版本太高”的问题多半就是JDK版本没跟上。前端Node.js装最新的LTS版本用nvm管理多个Node版本最方便。5.2 项目打包部署的两种方式开发完成后要上线我习惯用前后端分离的部署方式。后端打包mvn clean package -DskipTests打包后会生成target/enroll-system.jar用java -jar enroll-system.jar就能跑起来。如果要后台运行Linux服务器上可以用systemd创建一个service文件或者用nohup java -jar enroll-system.jar app.log 21 这种方式。前端构建npm run build构建产物在dist目录。用Nginx托管dist目录同时配置反向代理把/api请求转发到SpringBoot应用server { listen 80; server_name yourdomain.com; root /var/www/enroll/dist; index 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; } }注意location /里的try_files配置这是Vue Router的history模式必须的否则刷新子路由页面时会404。5.3 常见问题速查表开发过程中我记录了一堆问题和对应的解决办法整理成了一张速查表遇到问题直接对号入座。问题现象可能原因解决方案启动报ClassNotFoundException: com.mysql.jdbc.Driver驱动类名错误改成com.mysql.cj.jdbc.Driver连接数据库报Access denied for user密码错误或认证插件不兼容检查密码改MySQL认证插件为mysql_native_password控制台不打印SQL没有配置MyBatis日志在application.yml配置mybatis.configuration.log-impl前端请求接口报跨域后端没代理或没配置代理开发环境用Vite代理生产用Nginx反代刷新页面404Nginx没配置try_files加上try_files $uri $uri/ /index.html中文乱码数据库字符集不是utf8mb4建库时指定CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci日期字段差8小时时区配置问题连接URL加serverTimezoneAsia/Shanghai前端页面白屏JS报错或构建失败打开浏览器控制台看报错先本地跑dev模式排错上传图片后无法访问静态资源路径映射问题SpringBoot配置WebMvcConfigurer映射本地磁盘路径到URL5.4 几个不会写在文档里的经验最后分享几个我调试过程中得来的经验。第一MyBatis的Mapper接口和XML文件必须在同一个包路径下否则扫描不到。SpringBoot启动类上如果加了MapperScan记得确认扫描路径覆盖到了所有Mapper接口。XML文件放在resources/mapper目录配置里mapper-locations要对应上。第二分页查询不要自己写LIMIT直接用PageHelper但要注意PageHelper的线程安全问题。PageHelper通过ThreadLocal传递分页参数使用后必须消费掉否则线程池复用线程时会出现分页串数据。我的做法是在service层调用PageHelper.startPage后立刻执行查询中间不要穿插其他数据库操作。第三大数据量导出Excel时不要一次性查全量到内存。招生数据几万条的时候POI导出很容易内存溢出。我的方案是分批查询每次查5000条写完一批再查下一批这样内存占用一直是平稳的。第四日志一定要打到位。我在每个Controller的入口和出口都打了请求参数和响应结果日志生产环境排查问题的时候全靠这些日志定位。别嫌麻烦上线后你就会感谢当初的自己。这个系统从设计到落地前前后后大概两周多时间。做完之后我对SpringBoot Vue这套组合的理解比之前看书深入太多了。整个流程走下来从数据库设计、后端接口开发、前端页面联调再到打包部署每个环节都踩过坑也填了坑。如果你也想拿类似项目练手我的建议是别光盯着源码看自己动手从建表开始把整个流程走一遍遇到问题比看十遍代码都管用。