做Java Web开发这些年,被问得最多的一个需求就是:“这种前后端分离的管理系统到底怎么搭?”尤其是SpringBoot+Vue3+MyBatis这套组合,几乎成了国内Web开发从入门到实战的标准路径。今天我就以一套高校健康信息管理系统的源码实现为主线,把前后端分离项目的完整落地过程拆开来讲——从数据库设计、后端接口、前端页面,到联调部署,每一步我都会说明为什么这样做,以及实操中踩过的坑。这套源码用的是SpringBoot+Vue3+MyBatis+MySQL,业务场景是高校日常健康信息采集与管理,角色包含学生、辅导员、管理员三类,功能覆盖每日信息填报、异常上报、数据看板、公告发布。适合正在做课程设计、毕业设计,或者想系统学习全栈开发的同学参考。
1. 这套系统到底解决什么问题:从需求拆解到技术选型逻辑
1.1 业务场景与角色边界
先说业务。高校健康信息管理这个场景,表面上是个“信息采集系统”,本质上是一个高频、真实、带强时间约束的数据填报场景:学生在每天某个时间点之前完成信息上报,辅导员要实时掌握自己分管学生的填报状态,管理员则要看到全校维度的汇总情况。这三大类角色的诉求完全不同,数据库表设计、接口拆分、前端页面组织都要围绕这个差异来。
学生端最核心的诉求是“快”:打开页面、填完提交、查看自己历史记录,整个流程越短越好。所以学生端页面我做成一个表单页加一个记录列表,不做多余的信息堆砌。辅导员端的核心诉求是“全”:要看到管辖范围内所有学生的填报进度,能一键筛选未填报的人,能对异常记录做处理标记。管理员端的核心诉求是“看得见”:用户管理、部门维护、通知公告下发,以及基于全院系的上报率统计图表。
这三类角色如果不做权限隔离,代码会乱成一锅粥。我按角色类型在用户表里用一个role_type字段区分,然后前端路由用meta信息控制页面准入,后端接口用拦截器校验角色权限。你说为什么不用更细粒度的权限模型?因为这类业务场景就三类角色,权限边界非常清晰,RBAC的细粒度模型(角色+菜单+按钮权限)会引入大量冗余配置,对这个体量的系统是过度设计。把role_type加进JWT里,拦截器里做一次判断,够用且好维护。
1.2 为什么是SpringBoot+Vue3+MyBatis这套组合
选型这事,很多人会纠结“是不是不够新”。我的观点是:技术选型从来不是追新,而是匹配场景和团队维护成本。
后端用SpringBoot,理由很直接:Java生态最成熟的快速开发框架,起步快,社区资料多。一个启动类加几个注解就能把Web容器、配置绑定、依赖注入全部搞定。相比SSH时代的XML地狱,SpringBoot的自动化配置把开发者的心智从“配置框架”解放到“写业务”。
ORM选MyBatis而不是JPA,是我在这个场景下的明确选择。健康信息管理这类系统,后端不可避免要写多表关联查询、按条件动态统计、时间范围聚合。MyBatis直接写SQL,虽然多写几行XML,但每条SQL的执行逻辑一目了然,真出性能问题也容易定位。JPA确实在单表CRUD上更省事,但在一对多关联、复杂统计场景下,你反而要和“怎么让框架生成高效SQL”作斗争。对于以SQL为中心的管理系统,MyBatis的可控性优势大于JPA的开发效率优势。
前端选Vue3,很大程度是因为组合式API。这个项目的页面逻辑里,表单校验、列表加载、分页切换、状态重置这些逻辑要频繁复用,组合式API可以用一个自定义Hook把表格页的全部逻辑收拢起来,一个页面组件只要几十行代码就能跑起来。配合Vite的秒级冷启动,开发体验比Vue2时代的Webpack好一个档次。UI层用Element Plus,后台管理界面这种“表单+表格+弹窗”的密集场景,用它可以少写大量样式代码。
1.3 源码的整体工程结构
整个项目按前后端分离拆成两个工程目录:后端是标准的Maven多模块单模块结构,前端是Vite创建的Vue3工程。后端分包按照controller、service、mapper、entity、common、config、interceptor来组织;前端按views、router、stores、api、components、utils来组织。
这个分包方式看着平淡,但它是经过实战验证的“安全生产目录”。很多新手项目乱就乱在分层不清晰:逻辑写在Controller里、SQL拼在Service里、前端请求散落在每个组件中。我在这个源码里强制约束了分层规范:Controller只做参数接收和结果返回,Service里写业务规则,Mapper只碰数据库操作,前端所有请求必须走api目录下的封装函数,任何组件里不得直接出现axios实例。
2. 数据库设计:状态上报与审批流的核心表结构
2.1 用户、院系与角色的三张基础表
这个系统的基础数据是“人”和“组织”。用户表、院系表、角色不做成三张表做关联,而是在用户表里直接挂department_id和role_type。为什么这么设计?因为高校院系结构是固定的两层(学院->班级/专业),没有多级动态扩展的需求,单独做一张组织树反而让查询复杂化。用户表结构大致如下:
- id:bigint主键,自增
- username:varchar(50),唯一索引
- password:varchar(100),存BCrypt加密后的密码
- real_name:varchar(50)
- role_type:tinyint,1学生,2辅导员,3管理员
- department_id:bigint,关联院系表
- phone:varchar(20)
- status:tinyint,1启用,0禁用
- create_time、update_time:datetime
这里有两个容易被忽略但很重要的细节。第一个是密码字段长度,BCrypt加密后的字符串长度是60,很多新手设计成varchar(32),存进去就被截断,导致登录永远失败。第二个是role_type的魔法值问题,我建议在项目常量类里统一定义,比如UserRoleEnum.STUDENT.getValue(),而不是在代码里到处写死“1”“2”“3”。不然三个月后你自己都看不懂这个数字代表什么角色。
2.2 每日上报记录表:唯一索引是防重复的第一道防线
业务规则是“每人每天最多上报一条”,这类约束必须在数据库层面兜底。如果只在Java代码里先查再插,并发场景下两条请求同时通过查询就会产生两条重复数据。所以health_report表里我加上了一个唯一索引:
UNIQUE KEY uk_user_date (user_id, report_date)上报记录表的核心字段:
- id:bigint主键
- user_id:bigint,上报人ID
- report_date:date,上报日期
- temperature:decimal(4,1),体温记录
- health_status:tinyint,1正常,2异常
- location:varchar(255),当前所在地
- contact_info:varchar(255),紧急联系人/联系方式
- remark:varchar(500),备注
- create_time:datetime,上报时间
这个表设计的关键在于把“上报日期”和“上报时间”拆开,而不是混成一个datetime字段。为什么?因为业务上要查的是“今天谁没上报”“某一天的上报率”,用report_date做分组和条件查询,可以走索引,速度非常快。如果用create_time去做日期过滤,所有查询都得套DATE()函数,索引失效,数据量上来后就是全表扫描。
2.3 异常记录与通知公告表
异常记录表记录的是“某条上报数据被标记为异常后的人工处理流程”。字段包括:id、user_id、report_id、exception_type、description、status(1待处理,2已处理)、handle_user_id、handle_time、handle_remark。这表本质是一个轻量工单表,不复杂,但要注意status的流转一定要在后端Service层控制,不要在Controller里随手update。否则哪天改了状态枚举,连带三四处代码都要跟着改。
通知公告表比较简单:id、title、content、publisher_id、create_time、is_top。考虑到只有管理员能发公告,甚至不需要单独的发布权限表。查询公告时按is_top降序、create_time降序排列即可。我见过有人在公告表里加了一个target_role字段来区分“发给学生还是发给老师”,这个需求在这个系统里其实不存在——公告无非全校可见,不需要定向推送。
2.4 逻辑删除还是物理删除
用户表和部门表我用了逻辑删除(is_deleted字段),上报记录和异常记录用物理删除。理由:用户和部门是主数据,删除后历史记录里的关联信息会失去意义,逻辑删除可以保留数据线索;上报记录是流水数据,删除后只是少一条历史,不涉及外键完整性,物理删除更干净。这个取舍不是绝对的,但你要在项目一开始就定好规则,而不是每个表看心情加is_deleted,那才是灾难。
3. 后端工程落地:SpringBoot+MyBatis的常见坑与我的处理方式
3.1 分层的边界在哪里
后端我严格按照Controller->Service->Mapper三层来写。很多人会问:一个简单的查询,Service里就是一行调用,不写Service直接Controller调Mapper行不行?短期能跑,长期一定出问题。因为业务规则会不断叠加,比如上报接口,一开始只是插入一条记录,后来要加“当天重复提交校验”,再加“异常状态判断”,再加“推送通知”。如果一开始跳过Service层,这些规则后面要么堆在Controller里变肿,要么回头重构。我建议哪怕最简单的方法也走一遍Service,保持调用链的完整。
在这个项目里,我特别强调Service层的“事务边界”。默认情况下Spring事务只对RuntimeException回滚,这个坑值得单独说:如果在Service方法里catch了异常再抛一个自定义BizException,而BizException没有继承RuntimeException,事务不会回滚。所以我在common包里定义BizException时,明确让它继承RuntimeException。
3.2 MyBatis XML中那些让你排查半天的写法问题
MyBatis的核心是XML里的SQL。这个项目里我把所有SQL写在XML中,Mapper接口只定义方法签名。为什么不用注解SQL?因为这个系统有大量动态条件查询,注解里写<script>标签可读性极差,XML的<where>、<if>、<foreach>才是处理动态SQL的正道。
第一个容易踩的坑是动态where的首个条件前多了AND。很多人手写WHERE 1=1来规避,这在数据量小的系统里问题不大,但属于不太优雅的处理方式。更规范的做法是用<where>标签,它会自动处理第一个条件前的AND关键字。第二个坑是模糊查询的写法:LIKE '%' #{keyword} '%'这样写才是预编译的,安全且能用索引;新手常写成LIKE CONCAT('%', #{keyword}, '%'),效果一样但不好读。第三个坑是排序字段。ORDER BY ${sortColumn}是防不住的动态SQL注入点,我在源码里的处理方式是定义排序字段白名单,只允许create_time或id这两个值传入,其他一律回退默认排序。
3.3 分页查询不要自己写Limit
列表页和统计页都要分页。MyBatis分页我直接用PageHelper,用法很简单:查询前调用PageHelper.startPage(pageNum, pageSize),紧接着的Mapper查询会自动带上LIMIT。但有两个细节:第一,PageHelper只对紧随其后的第一条查询生效,这个大家都知道,但很多人不知道如果查询前有别的数据库操作,分页就失效了。第二,在多表关联查询和嵌套ResultMap的场景下,PageHelper的count查询可能统计不准确,所以我做列表查询时尽量避免复杂的嵌套映射,多表关联就拆成单表查询后内存组装,或者写专门的VO查询类。
3.4 登录鉴权:用拦截器而不是Spring Security
登录鉴权我用的方案是JWT+HandlerInterceptor。为什么不引入Spring Security?对这个体量的系统,Security的过滤器链和配置复杂度远远超出需求,而且它和SpringBoot版本选型耦合度高,一旦版本不匹配,配置半天调不通,纯属给自己加负担。用拦截器,逻辑透明,所有代码都在掌控之中。
实现要点有几个。第一,登录接口在拦截器里要放行,不然用户还没登录就被拦了。第二,登录成功后生成JWT,在Token里只放userId和roleType,不放密码等敏感信息。第三,拦截器里解析Token如果失败,直接返回401状态码和统一错误格式,而不是抛出异常让全局处理器去处理。第四,用户信息用ThreadLocal保存,这样Controller和Service里随时可以取当前登录用户ID,这在填写上报记录时特别方便——不需要前端传userId,后端从Token里拿,防止伪造。
统一返回格式我用Result<T>,结构是code、msg、data三件套。200表示成功,401表示未登录,500表示系统错误,业务异常用自定义code。所有接口返回这个结构,前端就不需要为每个接口单独写错误判断逻辑。全局异常处理用@RestControllerAdvice,捕获BizException返回业务错误,捕获参数校验异常返回参数错误,捕获Exception返回系统错误。
4. 前端 Vue3 工程化:从路由守卫到组件拆分
4.1 工程初始化与目录约定
前端我用Vite创建,命令是npm create vite@latest。很多初学者会纠结要不要用TypeScript,我的建议是:如果项目是课程设计或者个人学习,JavaScript完全够用;如果团队协作并且有规范偏好,再上TypeScript。这个源码我没用TS,不是TS不好,而是这套业务逻辑比较简单,类型系统带来的约束收益不高,反而增加了模板代码。
目录结构比我前文说的更具体一点。views下面按角色分子目录:studentViews、teacherViews、adminViews,每个角色页面互不混用。api目录按业务域拆分文件:user.js、report.js、notice.js、statistics.js。stores目录用Pinia,这里主要存两个全局状态:当前登录用户信息和侧边栏折叠状态。组件复用层面,我封装了一个CommonTable组件和一个CommonFormDialog组件,管理员端的用户管理、上报记录管理等好几个页面都能复用它们。
4.2 Axios拦截器与Token生命周期
Axios封装是整个前端的命脉。我在utils/request.js里创建了一个axios实例,然后设置了两层拦截器。
请求拦截器做的事情比较简单:从localStorage取Token,有就加到请求头Authorization字段。这里有一个大坑:Token的存储位置。我见过不少项目把Token存在localStorage里,页面刷新后还在,但遇到XSS攻击时Token会泄露。更稳妥的做法是用HttpOnly Cookie,但这个方案会带来CSRF防护需求,复杂度上来了。考虑到课程设计和中小型项目的实际威胁模型,localStorage其实足够,注意别用eval、别把用户输入直接innerHTML就行。
响应拦截器要做的事情更多:拿到后端返回的data后,先判断业务code。如果是401,说明Token过期或无效,直接清掉本地Token并跳转登录页,同时用Element Plus的Message给用户提示;如果是200但业务code不是200,说明业务执行失败,统一弹错误提示。这样设计后,页面里写请求代码根本不需要处理错误分支,只需要关心成功后的渲染。
4.3 路由守卫:前后端都要有防线
路由守卫的逻辑分三块。第一块:beforeEach里判断目标路由是否需要登录,需要登录但没Token就跳登录页。第二块:有Token但访问了无权限的角色页面,通过meta.roles做拦截,比如学生角色访问adminViews下的路由,直接重定向到403页面。第三块:登录页在已登录状态下访问,跳转到对应角色的首页。
前端做路由守卫的作用是用户体验,而不是安全。真正防越权的是后端拦截器,前端躲开只是让界面不出现一堆无权限报错。我在源码的注释里专门强调了这一点,因为很多初学者把前端权限当安全防线,一旦绕过前端直接请求后端接口,就裸奔了。
4.4 核心页面:填报表单与表格看板
学生端的填报页面是这个系统最体现交互细节的一个页面。逻辑不复杂,但要做好两个状态:今天是已经填报还是未填报。页面加载时请求后端判断今日是否已报,如果已报,表单全部禁用,只显示“今日已填报”的状态;如果未报,表单可用。这里有一个体验细节:提交成功后的按钮立刻变成loading状态并置灰,防止用户连点提交造成重复请求,虽然后端有唯一索引兜底,但前端也要主动避免无效请求。
管理端的表格看板页面,我封装了一个usePageTable组合式函数,把搜索条件、分页参数、列表数据、加载状态、查询方法、重置方法全部收进去。页面组件里只需传一个获取列表的API函数和搜索表单字段,就能自动组装出完整表格页。这就是组合式API相对选项式API的优势,逻辑复用是自然的函数式组合,而不是mixin的命名空间污染。
5. 前后端联调:跨域、日期格式、空值这些小事如何逼疯人
5.1 开发环境的跨域:用Vite代理而不是后端开CORS
前后端分离项目,联调时第一个遇到的就是跨域。前端跑在5173端口,后端跑在8080端口,端口不同就产生了跨域。我推荐开发环境用Vite的proxy配置解决:所有以/api开头的请求,Vite在开发服务器层面转发到8080端口的后端服务。
// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这样配置后,前端代码里请求路径直接写/api/report/submit,浏览器看到的请求是同源的,不会触发跨域拦截。很多人图省事在后端加一个@CrossOrigin放开所有来源,这在开发环境可以,但生产环境如果也把CORS全放开,任何网站都能往你的接口发请求,非常危险。我在源码里后端的CORS配置是可选的,生产环境走Nginx同源部署,完全不需要后端放跨域。
5.2 Long类型精度丢失:前端拿到的ID不对
这个坑极其隐蔽。数据库的bigint类型对应Java的Long类型没问题,但JSON序列化后,前端JavaScript的Number类型只能精确表示2^53以内的整数。MySQL自增ID一旦超过这个范围(比如雪花ID或者数据量大的系统),前端拿到的ID尾数就是错的,然后拿着这个错误的ID去请求详情或删除,后端自然查不到数据。
解决方案在后端,全局给Long类型的序列化做一个处理:在application.yml里配置Jackson,把Long序列化为String。当然这会影响所有Long字段,如果只是个别字段需要,就在实体字段上加@JsonSerialize(using = ToStringSerializer.class)。这个系统的用户ID在数据量不大时不会超过精度范围,但既然做了通用源码,我顺手就在全局配置里处理了,一劳永逸。
5.3 日期时间格式:前后端各管一段
后端LocalDateTime序列化默认输出的是"2025-01-01T10:30:00"这种ISO格式,Element Plus的日期时间组件默认格式是"2025-01-01 10:30:00"。如果不统一,前端表格里显示的日期要么带T,要么和组件解析不一致导致回显错误。我在后端全局配置了Jackson的日期格式为yyyy-MM-dd HH:mm:ss,同时指定time-zone: GMT+8,防止时区偏差导致时间少8小时。
前端这边有两个需要注意的点。第一个,Element Plus的date-picker组件要设置value-format="YYYY-MM-DD",这样绑定值就是一个纯日期字符串,提交给后端时不需要再format。第二个,后端查询统计的时候,接收前端传的日期参数用@DateTimeFormat(pattern = "yyyy-MM-dd")注解,明确告诉Spring怎么把字符串转成Date对象。
5.4 空值字段的前端兜底
联调最容易出现的另一个现象是:后端返回的字段里有null,前端组件里直接row.remark.length就报错了。后端接口设计时,我建议全局配置Jackson的NON_NULL,序列化时忽略null字段,这样响应体干净紧凑,前端也不会解出多余的null。但有些字段前端需要知道“有没有值”来做展示逻辑,比如异常记录的处理备注,null代表未处理,空字符串代表处理时没填备注,这个语义差异前端要区分。
所以更稳妥的办法是前端代码里用可选链row.remark?.length和空值兜底row.remark || '--'来处理展示,而不是依赖后端屏蔽null。我在项目的表格列渲染里统一写了一个formatValue函数,null和空串都显示为占位符,看着规整,也不会出现控制台报错。
6. 项目源码使用与部署:从本地跑起来到生产服务器
6.1 拿到源码后第一步:怎么跑起来
先说明这套源码的本地运行前置条件,JDK要求17以上(SpringBoot 3.x的硬性要求),MySQL要求8.0,Node要求18以上。第一步导入数据库,项目根目录下有个sql目录,里面是建库建表脚本和初始化数据。执行脚本时我建议用命令行或者Navicat直接source,不要复制粘贴到图形化工具的查询窗口里执行——脚本比较长,窗口执行容易一半中断。
第二步改后端配置。打开application-dev.yml,把数据库连接地址、用户名、密码改成你自己的。特别注意MySQL连接串里的时区参数serverTimezone=Asia/Shanghai,不配这个参数,高版本MySQL驱动会报时区错误。第三步启动后端,IDE里运行主类,或者命令行mvn spring-boot:run。第四步启动前端,先npm install装依赖,然后npm run dev,浏览器访问http://localhost:5173。
这里说一个新手经常卡住的问题:npm install很慢或者报错。我的建议是先确认npm源,如果默认源访问慢,切换成国内镜像源再装。如果node_modules装了一半中断,把整个目录删掉重装,不要想着修复,node_modules的状态是没法保证的。踩过太多次这种坑了。
6.2 Maven多环境配置
数据库密码这种事,绝对不能写死在代码里提交到仓库。我的做法是后端配置拆三个文件:application.yml放公共配置,application-dev.yml放本地开发配置,application-prod.yml放生产配置。生产配置里的数据库密码用环境变量占位,比如${DB_PASSWORD},部署时在服务器环境变量里注入真实值。
打包命令是mvn clean package,生成的jar包里有完整的内嵌Tomcat。如果你用IDE直接启动,用dev环境;部署服务器,用java -jar xxx.jar --spring.profiles.active=prod指定生产环境。这里有个细节:SpringBoot 3.x和Maven插件版本有兼容性要求,pom.xml里spring-boot-maven-plugin版本要和SpringBoot父版本一致,不然打包后运行会报“no main manifest attribute”错误。
6.3 Nginx部署与前端路由history模式
部署这块我单独说一下,因为前端路由的坑太典型了。前端用Vue Router的history模式后,路由路径不是带#的hash模式,用户在浏览器直接访问http://服务器IP/student/dashboard这个地址时,Nginx找不到这个物理路径,会返回404。解决办法是在Nginx配置里加上try_files回退:
location / { root /usr/share/nginx/dist; index index.html; try_files $uri $uri/ /index.html; }Nginx的另一个配置是API反向代理。前端构建后部署在服务器的80端口,后端jar包跑在8080端口,需要把/api/开头的请求转发到后端:
location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这里有一个容易配错的地方:proxy_pass后面如果带了路径http://127.0.0.1:8080/,请求的/api前缀会被去掉;不带路径,则完整透传。我用的是完整透传方式,因为后端所有Controller都是挂在/api下面的,保持一致。启动后端时用nohup java -jar 项目名.jar > app.log 2>&1 &放后台跑,然后把日志写入app.log,排查问题全靠它。
6.4 服务器初始化数据与定时任务
生产环境第一次启动时,需要执行初始化SQL创建表和管理员账号。我建议初始化管理员密码直接用脚本里生成的BCrypt哈希值,而不是明文,避免被人看到源码就知道所有系统初始密码。另外,健康信息管理场景需要每日零点自动重置当天的填报状态,虽然接口层面是通过“查询今日是否已填报”来判断的,不需要真的重置数据,但如果你要跑定时统计,可以在后端加一个@Scheduled注解的定时任务,每天凌晨算出前一天的各部门上报率并缓存起来。
定时任务看起来简单,但要注意两点:第一,默认单线程调度,多个任务要用线程池配置@EnableAsync和@Async,否则一个任务阻塞会影响其他任务;第二,生产环境多个后端实例部署时要防止定时任务重复执行,简单方案是加一个数据库锁,或者你这个体量就用分布式锁框架,这个项目加锁也可以用数据库唯一键实现,总之不要裸跑。
7. 开发完这套系统后的几点实在话
做完整套系统,最深的体会有三点。
第一,先跑通,再优化。我在最初版本里根本没加Redis缓存、没有消息队列、没有复杂权限模型,就是一个极简的SpringBoot+Vue3 CRUD系统。先把主流程跑通,学生能登录、能填报、管理员能看统计,之后所有优化都是在主流程之上的锦上添花。很多同学做项目一开始就想把所有技术栈堆上去,结果卡在集成问题上,项目进度一拖再拖。
第二,日志是定位问题的最好朋友。整个后端代码里,我几乎没有用System.out.print,全部用SLF4J的log.info/log.error。遇到接口返回500,不要急着猜,先看日志栈。前端也一样,遇到页面不刷新,打开浏览器控制台看Network面板和后端返回数据。这个习惯能省下大量联调时间。
第三,不要过度设计。这个系统本质上是一个典型的管理信息系统,它的核心价值在于数据能够准确、及时地流转,而不是用了多少新潮技术。如果你在做一个课程设计或毕设,我的建议是把基础功能做扎实,比堆一堆只会跑Demo的新技术更能拿高分。后续想扩展,可以往这些方向走:对接企业微信或钉钉的消息通知、引入二维码扫码填报、增加Excel导入导出、用ECharts做更丰富的趋势分析。这些扩展都不需要推翻现有架构,你只需要在Service层和前端新增模块即可。
这套源码你拿去跑起来,对着代码看我这篇文章,很快就能看明白整个链条。如果遇到环境问题跑不起来,优先看后端的启动日志和前端的构建报错,十有八九是端口、数据库连接或Node版本这三个方向。祝顺利跑通。