SpringBoot2+Vue3搞了一套校园健康驿站管理系统,最近刚把工程重构完,配套的说明文档也整理齐了。这套项目从最初的学生健康信息手动登记到现在的全流程线上化,中间踩了不少坑,也积累了一些技术细节,正好趁着这次重构完整体梳理一遍。项目整体并不复杂,但涉及的技术栈比较典型——后端SpringBoot2+MyBatis-Plus+MySQL8.0,前端Vue3+Vite+Element Plus,对于想完整走一遍前后端分离项目的同学来说,是个不错的参考对象。这篇文章会把项目从需求梳理、表结构设计到前后端关键代码实现、部署文档整理的过程完整串一遍,讲清楚每个环节为什么这么选型、怎么落地、有哪些隐蔽的坑。
1. 项目从需求到落地的整体思路
校园健康驿站这个名词听起来有点行政味道,本质上就是一套面向校内师生的健康信息管理平台。核心要解决的痛点很直接:以前体温异常登记、每日健康打卡、隔离观察记录都靠辅导员手动填Excel,数据分散、时效性差,汇总起来极其痛苦。这个系统就是要用一套流程把“健康上报—异常预警—驿站入住登记—日常巡检—解除观察”的整体链路管理起来。
1.1 核心角色与业务闭环拆解
站在实际使用场景里,系统里的角色其实分得很清楚:学生、辅导员(或班主任)、校医/驿站管理员、系统管理员。四类角色的操作路径完全不一样,这也是前后端权限设计和接口设计的基础。
学生的核心操作是每日健康打卡、查看自己的上报记录、在需要的时候提交驿站入住申请或者登记异常情况。辅导员的职责是查看所带班级的健康数据、对异常情况进行跟进处理、审批学生的入住申请。校医/驿站管理员的日常工作最重,需要处理学生入住安排、分配床位、每日巡检记录、体温复核、解除观察等操作。系统管理员则负责基础数据维护,包括院系班级、宿舍楼栋、床位信息、用户账号管理。
业务闭环大概是这么走的:学生每日健康打卡,若体温异常或勾选异常症状,系统自动生成预警记录;辅导员看到预警后进行初步确认;需要隔离观察的学生在线提交入住申请;校医审批通过后分配驿站床位;入住期间每日录入巡检和体温数据;观察期满且数据正常,执行解除操作。整个流程如果靠纸质登记,链条上任何一环出现问题都很难追溯,这也是这套系统存在的最直接原因。
1.2 项目功能模块划分与边界界定
实际开发时,我把功能模块按角色边界切成四大块,避免前后端接口范围模糊导致返工。
- 学生端:每日健康上报、上报历史查询、入住申请提交、申请进度查看、个人通知列表
- 辅导员端:班级学生健康数据看板、异常预警处理、入住申请审批、导出报表
- 驿站管理员端:床位和房间管理、入住登记与分配、每日巡检录入、体温趋势查看、解除观察操作
- 系统管理端:用户管理、角色权限管理、院系/班级维护、房间床位初始化、操作日志
这套划分方式在实际开发中帮了大忙。比如学生端根本不会出现任何管理类接口,前端路由也直接做角色区分,后端的拦截器按角色校验接口权限,误调用直接返回403。边界清晰以后,前后端联调时几乎不需要互相问“这个接口给谁用”的问题。
2. 技术栈选型与核心原理剖析
项目定位是高校学生健康管理场景,对并发要求不算变态,但要求开发效率高、团队上手快、后期维护成本低。这套选型(SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0)恰好是在当前国内高校开发团队中比较通用的一套组合。
2.1 后端为什么选SpringBoot2而不是SpringBoot3
SpringBoot2在现阶段依然是企业级项目里最稳的选择。SpringBoot3虽然已经发布,但它基于JDK17,很多学校机房或服务器上的JDK版本还停留在1.8,直接升级成本很高。这个项目我选了SpringBoot2.7.x版本配合JDK1.8,部署到服务器时不需要额外折腾JDK升级,各种中间件的兼容性也都有很成熟的方案。
SpringBoot2带来的核心价值不是某个单独的功能,而是它极大地简化了Spring生态的集成过程。以前做SSM项目要写大量XML配置,现在一个spring-boot-starter-web就把内嵌Tomcat、MVC、JSON序列化全带进来了。项目里我实际用到了SpringBoot的自动配置、条件装配、配置绑定、AOP做操作日志这几个关键能力。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent>版本为什么锁2.7.18?这是SpringBoot2.x的最后一个社区维护版本,bug修复和安全补丁覆盖最全,同时又保留了2.x的所有兼容特性。我试过用2.7.14做开发,后来升级到2.7.18,期间没有遇到任何兼容性问题。
2.2 MyBatis-Plus相比原生MyBatis解决了什么
很多人对MyBatis-Plus有个误解,以为它是个重量级框架,实际上它只是MyBatis的一个增强工具包,只做增强不做改变。它帮我省掉的重复工作量非常可观。
举一个最典型的例子:分页查询。原生MyBatis要做分页,得自己写LIMIT #{offset}, #{pageSize},还要手动查total数,再封装Page对象。MyBatis-Plus提供一个PaginationInnerInterceptor就能自动完成物理分页和count查询,代码里只需要:
Page<HealthReport> page = new Page<>(current, size); LambdaQueryWrapper<HealthReport> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(HealthReport::getStudentId, studentId) .orderByDesc(HealthReport::getReportDate); IPage<HealthReport> result = healthReportMapper.selectPage(page, wrapper);底层流程是这样的:selectPage方法执行前,分页插件会拦截SQL,自动改写生成一条COUNT(*)查询和一条带LIMIT的数据查询。这个过程中完全不需要手写XML,代码量直接腰斩。
另外一个好用的是LambdaQueryWrapper。它在编译期就通过Lambda表达式得到字段名,避免了硬编码字符串列名到处飘。比如按班级ID和学生姓名组合查询,直接:
wrapper.eq(ClassInfo::getId, classId) .like(StudentInfo::getStudentName, keyword);就算后面表结构字段改了,这里编译就会报错,不会等到运行时才炸。
2.3 Vue3组合式API与响应式原理的工程化优势
前端选Vue3,核心原因是组合式API(Composition API)解决了Vue2里逻辑碎片化的问题。做后台管理系统时,一个表格页面往往涉及数据列表、筛选条件、分页、弹窗、表单验证、批量操作,这些逻辑分散在Vue2的data、methods、watch里,代码稍微长一点就变成意大利面。
Vue3的组合式API允许我把同一功能相关的代码聚在一起。比如健康上报页面的逻辑,我拆成了三个useXxx组合函数,在组件里按需组合:
// 上报逻辑 const { reportForm, submitReport } = useHealthReport() // 历史记录加载 const { recordList, loading, loadRecords } = useReportHistory() // 状态标识计算 const { todayReported, statusText } = useReportStatus()每个useXxx函数把这些变量和逻辑封装在内聚的代码块里,哪个页面需要就引入哪个函数,这才是真正意义上的“逻辑复用”。
响应式方面,Vue3用Proxy替换了Vue2的Object.defineProperty。这带来的实际提升是:新增属性也会被响应式追踪,不需要像Vue2那样调用this.$set();数组操作(通过下标修改)也无需再做兼容处理;配合ref和reactive提供的灵活API,还能做更细粒度的响应式控制。
2.4 MySQL8.0的升级收益与兼容处理
数据库选型方面,MySQL8.0相比5.7带来了几个切实的收益。默认字符集从latin1变成了utf8mb4,存储表情符号或生僻人名时不会再出现乱码;窗口函数(如ROW_NUMBER()、RANK())可以直接在SQL里做排名和分组统计,不需要绕道代码层处理;JSON类型的功能大幅增强。
但8.0也带来了一些升级要注意的地方。最典型的是驱动类名变了,老项目里的com.mysql.jdbc.Driver在8.0中已经不推荐使用,必须用com.mysql.cj.jdbc.Driver。URL中还必须显式配置serverTimezone参数,否则连接会直接报时间相关的异常。实际配置如下:
spring: datasource: url: jdbc:mysql://localhost:3306/health_station?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.DriverallowPublicKeyRetrieval=true这个参数是很多新手第一次连8.0最容易漏的。MySQL8.0默认使用caching_sha2_password插件认证,如果客户端连接时不允许获取RSA公钥,Non-SSL连接下就会抛出Public Key Retrieval is not allowed异常。本地开发强密码策略下更明显,所以这个参数我一般都会顺手加上。
3. 数据库表结构设计与核心模块实现
表结构设计直接决定业务功能能不能顺畅落地。这套健康驿站系统我一共设计了十张核心表,这里挑几张有代表性的表拆开讲解,把字段设计的思考过程一并说出来。
3.1 健康上报表的字段设计与索引规划
健康上报是整个系统的数据源头,一天可能产生上万条记录。health_report表的结构如下:
CREATE TABLE `health_report` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `student_id` bigint NOT NULL COMMENT '学生ID', `report_date` date NOT NULL COMMENT '上报日期', `temperature` decimal(4,2) DEFAULT NULL COMMENT '体温', `is_abnormal` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否异常 0否 1是', `symptom_desc` varchar(500) DEFAULT NULL COMMENT '异常症状描述', `contact_history` varchar(500) DEFAULT NULL COMMENT '接触史描述', `location` varchar(200) DEFAULT NULL COMMENT '当前所在地', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_student_date` (`student_id`, `report_date`), KEY `idx_date_abnormal` (`report_date`, `is_abnormal`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康上报信息表';三个索引的用途各不相同。idx_student_date用于支撑“查询某学生在某日期范围内的上报记录”这类高频查询;idx_date_abnormal服务于管理员“查看某天所有异常记录”的场景;idx_create_time用于日报、统计类查询的排序。设计索引时要站在实际查询模式去想,而不是盲目给所有字段加索引。
这里有个实际开发中的细节:每天每个学生理论上只能上报一次,我通过student_id + report_date构建了业务唯一约束,但并没有直接建唯一索引。原因是这个系统允许辅导员代学生补报,同一时间点可能会有多个人同时提交同一学生的记录,为了保证数据先落库再人工校验,最终采用了应用层先查询再插入的方案,配合事务控制。如果未来并发量真的大到需要硬约束,再把唯一索引加上也不迟。
3.2 用户角色权限的多表设计与JWT会话机制
用户相关的表拆成了sys_user、student_info、teacher_info、role、user_role五张。没有把学生信息、教师信息都塞进sys_user一张表的原因是:两类用户的基础属性差异大(学号、专业、班级 vs 工号、职称、院系),强行合并会造成大量字段空置,查询时必须不断判空,维护起来非常难受。
权限模型用的是经典的RBAC(基于角色的访问控制)设计。sys_user只保存账号密码和基础状态,student_info保存学生专属信息,通过user_role关联到角色,角色再关联到菜单权限。登录时后端一次性查出用户的基本信息和角色集合,生成JWT令牌,后续请求只需在拦截器里解析令牌、拿用户ID和角色标识。
JWT的集成要点在OncePerRequestFilter过滤器里体现:
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = Jwts.parser() .setSigningKey(jwtSecret) .parseClaimsJws(token) .getBody(); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); } catch (ExpiredJwtException e) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"登录已过期,请重新登录\"}"); return; } catch (JwtException e) { response.setStatus(401); response.getWriter().write("{\"code\":401,\"message\":\"无效的令牌\"}"); return; } } chain.doFilter(request, response); }JWT的令牌里只放userId和role,不放大对象,就是为了减小token体积、降低泄露风险。过期时间设置为8小时,配合前端的路由守卫做跳转,用户体验和安全性平衡得还不错。
3.3 后端三件套:Controller、Service、Mapper的标准写法
后端代码遵循了经典的Controller-Service-Mapper三层结构。Controller层只负责参数校验和结果封装,不写任何业务逻辑;Service层承载所有业务规则;Mapper层用MyBatis-Plus的BaseMapper提供基础CRUD。以“学生提交健康上报”为例,Controller的核心代码长这样:
@PostMapping("/report") public R submitReport(@RequestBody HealthReportDTO dto, @RequestAttribute Long userId) { healthReportService.submitReport(userId, dto); return R.success(); }Service层才是有血有肉的地方:
@Transactional(rollbackFor = Exception.class) public void submitReport(Long userId, HealthReportDTO dto) { StudentInfo student = studentInfoService.getByUserId(userId); if (student == null) { throw new ServiceException("未找到学生信息,请联系管理员"); } LocalDate today = LocalDate.now(); long count = healthReportMapper.selectCount( new LambdaQueryWrapper<HealthReport>() .eq(HealthReport::getStudentId, student.getId()) .eq(HealthReport::getReportDate, today) ); if (count > 0) { throw new ServiceException("今日已上报,请勿重复提交"); } HealthReport report = new HealthReport(); BeanUtils.copyProperties(dto, report); report.setStudentId(student.getId()); report.setIsAbnormal(dto.getTemperature() >= 37.3 || StringUtils.hasText(dto.getSymptomDesc()) ? 1 : 0); healthReportMapper.insert(report); if (report.getIsAbnormal() == 1) { // 生成异常预警 abnormalAlertService.generateAlert(report); } }在提交温度的公式计算上,我特意把异常判断阈值设定为37.3℃,这是健康管理里比较基础的标准,实测下来准确率不错。如果有一天管理部门要求调整阈值,只要把temperatureThreshold配置到application.yml里,然后从这里读取配置即可,完全不需要改代码。
@Transactional注解标在Service方法上,后面任何一步抛异常,前面的插入操作都会回滚,避免“上报成功但预警没生成”这种半截状态。
3.4 前端路由权限控制与Pinia状态管理
前端路由用了vue-router4的addRoute动态注册方式实现角色权限控制。核心思路是在用户登录成功后,后端根据角色返回可访问的菜单列表,前端动态加载对应路由组件再挂载到路由表上。
// 路由守卫 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token) { if (to.path === '/login') next() else next('/login') return } const userStore = useUserStore() if (!userStore.roles.length) { userStore.fetchProfile().then(() => { const dynamicRoutes = generateRoutes(userStore.roles) dynamicRoutes.forEach(route => router.addRoute(route)) next({ ...to, replace: true }) }) return } next() })Pinia在这里替代了Vuex,主要承担用户信息、路由菜单、全局枚举字典的共享。Vuex里那种Mutation、Action的套路在中期会让人写得很烦,Pinia直接用defineStore定义store,里面既可以写state也可以写action,不需要额外引入复杂概念,使用体验清爽得多。这个项目里的useUserStore大概长这样:
export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('token') || '') const profile = ref(null) const roles = ref([]) async function fetchProfile() { const res = await getUserInfo() profile.value = res.data roles.value = res.data.roles } function logout() { token.value = '' profile.value = null roles.value = [] localStorage.removeItem('token') } return { token, profile, roles, fetchProfile, logout } })3.5 Element Plus表格与表单的封装实践
Admin系统里最多的是表格页面和表单弹窗。如果没有封装,每个页面都会重复大量表格和弹窗模板代码。我单独封装了一个公共组件ProTable.vue,接收列配置、查询接口、表单配置,内部统一处理loading、分页、刷新、多选等逻辑。封装的表格配置结构大概是这样的:
const columns = [ { prop: 'studentName', label: '姓名', minWidth: 100 }, { prop: 'className', label: '班级', minWidth: 120 }, { prop: 'temperature', label: '体温', minWidth: 90 }, { prop: 'reportDate', label: '上报日期', minWidth: 120 }, { prop: 'isAbnormal', label: '状态', minWidth: 90, slot: 'status' }, ]封装以后,新增一个查询页面只需要写一个几十行的配置文件和组件调用,不用再复制一份几百行的表格模板。在团队协作时,这种基础组件的沉淀往往比业务代码本身更有复利价值。
4. 项目中那些值得细品的设计细节
4.1 全局异常处理体系的设计
统一异常处理是很多项目早期最容易忽视的部分。团队开发时,每个人处理异常的风格都不一样,有的返回null,有的返回字符串"error",有的直接把500错误页抛给用户,前端联调时非常头疼。
我在这套系统里统一了响应结构:
{ "code": 200, "message": "success", "data": {} }同时定义了一个GlobalExceptionHandler,用@RestControllerAdvice捕获业务异常、参数校验异常、服务器未知异常,转换成统一的JSON结构返回。前端Axios响应拦截器里统一判断code字段,非200则弹Message提示错误信息。
系统里自定义的ServiceException在Service层手动抛出,专门表达“前置条件不满足、业务规则被打破”等情况。例如前面提到“今日已上报,请勿重复提交”就是在业务代码里主动抛出的。这种设计的好处是,业务异常和系统异常被分隔开,线上排查时看日志,能清楚区分哪些是用户操作问题、哪些是系统bug。
4.2 下拉字典和状态枚举的前后端约定
这个健康驿站项目里有大量状态值——上报状态、审批状态、巡检状态、酒店房间状态。如果代码里到处用魔法数字写死,后期改一个状态位的含义,改动范围会爆炸。我在前后端都做了枚举/字典的约定:
后端通过@EnumValue注解和MyBatis-Plus的枚举映射,把Java枚举和数据库的int值做对应。前端则维护一个全局的字典文件,把数字映射成显示文案:
export const REPORT_STATUS_MAP = { 0: { label: '正常', type: 'success' }, 1: { label: '异常', type: 'danger' }, }页面上的状态列直接用这个映射渲染,Tag标签的颜色也跟着status走,UI统一、改起来方便。更关键的是,这个约定让前后端开发同步进行时,后端不用等前端问“这个数字1是什么意思”,一份字典表就够了。
4.3 时间处理与时区的那几个大坑
Java时间处理是个高发事故区,这个项目里也没幸免。团队里有一部分人习惯用Date,一部分习惯用LocalDateTime,前后端传参时格式经常对不上。加上MySQL8.0的时区设置,以及Jackson默认序列化格式,若不统一规范,接口返回的时间会在前端显示成时间戳或带T的ISO标准格式,体验非常不好。
我当时做的统一规范如下:
application.yml中强制Jackson序列化和反序列化格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8实体类中的日期字段统一使用LocalDateTime类型(配合@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"))。数据库层统一用datetime类型存储,MySQL连接的serverTimezone参数设置为Asia/Shanghai。三者保持一致后,接口传参和返回就再也没出过时间错乱的问题。
还有一个细节容易被忽视:前端Element Plus的DatePicker组件,默认value-format是时间戳或者Date对象,如果不显式指定value-format="YYYY-MM-DD",传到后端的可能就是奇怪的对象,很容易造成异常。所以我在封装表单组件时,日期选择器一定显式写好value-format。
5. 部署方案、环境准备与文档整理思路
项目的开发环境只是第一步,真正让这个系统跑起来的是部署环节。很多同学开发时一切正常,一到服务器上就状况百出。这里把部署中涉及的环境准备和配置详细拆开讲一下。
5.1 Docker快速部署MySQL8.0实战
服务器上我使用的方案是Docker部署MySQL8.0,避免直接在宿主机上装MySQL污染环境,也方便未来迁移和扩展版本。一条命令就能把MySQL跑起来,但要注意细节:
docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=Root2024! \ -e TZ=Asia/Shanghai \ -v /data/mysql/conf:/etc/mysql/conf.d \ -v /data/mysql/data:/var/lib/mysql \ --restart=always \ mysql:8.0这里必须注意几个点。TZ=Asia/Shanghai环境变量为了让容器内的时间和宿主机保持一致,不然后端写入数据库的时间会和真实时间差8个小时。数据目录挂载到宿主机/data/mysql/data,这样容器删了重建数据也不会丢。
启动容器后,还需要手动创建数据库并指定字符集:
docker exec -it mysql8 mysql -uroot -p进入MySQL命令行后执行:
CREATE DATABASE IF NOT EXISTS health_station DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;一定要在建库时指定utf8mb4,否则默认的latin1字符集会让你在写入中文时出现一堆问号。建库时顺手指定COLLATE也很重要,因为utf8mb4_unicode_ci和utf8mb4_general_ci在排序规则细节上有差异,如果前后环境不一致,后续数据同步可能会遇到排序不一致的问题。
5.2 前后端构建配置与打包上传细节
后端打包用Maven,执行mvn clean package -DskipTests生成JAR包,部署时用nohup java -jar health-station.jar启动。为了让进程在后台稳定运行和日志可查,我使用如下启动脚本:
#!/bin/bash APP_NAME=health-station.jar LOG_FILE=/data/logs/health-station.log nohup java -Xms512m -Xmx1024m -jar $APP_NAME --spring.profiles.active=prod > $LOG_FILE 2>&1 & echo "Started $APP_NAME with PID: $!"指定--spring.profiles.active=prod,就能自动加载application-prod.yml里的生产环境配置(数据库地址、密码、日志级别等),本地开发环境和生产环境的配置互不干扰。
前端使用Vite构建,打包命令是npm run build。需要注意Vite默认的base路径是/,如果网站部署在域名根路径下没什么问题,如果要部署在子路径(比如/admin/),就必须在vite.config.js里设置base: '/admin/'并调整后端静态资源目录的代理配置。我在项目里的实际做法是单独的vite.config.prod.js,内容如下:
export default defineConfig({ base: '/', plugins: [vue()], server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }, build: { outDir: 'dist', assetsDir: 'assets', chunkSizeWarningLimit: 1500 } })开发环境下通过proxy把/api开头的请求代理到后端8080端口,解决跨域;生产环境下前端打包出的dist目录里的静态文件由Nginx负责托管,Nginx再将/api开头的请求反向代理到Java后端。完整Nginx配置片段如下:
server { listen 80; server_name your-domain.com; location / { root /data/www/health-station; 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这行必须加上,否则前端Vue的History模式路由在刷新页面时会出现404。这一点是整个部署过程中最容易踩的坑,见过太多人部署后觉得“页面白屏”或“刷新就404”,其实都是这行配置缺失导致的。
5.3 项目文档应该怎么写才能帮到后来人
标题里写了“含文档”,我在这套系统里整理的文档体系分为四层。
第一层是部署文档,讲清楚环境依赖、初始化配置、启动步骤。第二层是接口文档,每个接口的请求方法、URL、参数、返回示例都写明,直接用于前端对接。第三层是数据库文档,包含每张表的字段含义、关联关系、索引说明,维护数据库的人只需要看这个文档就能快速定位字段。第四层是使用手册,面向最终用户(学生、辅导员、管理员),用截图和步骤说明指导日常操作。
写文档时我特别强调一个原则:开发文档必须和代码同步更新。很多项目文档写完了就丢在一边,等接手的人发现文档和代码对不上时,文档的价值反而变成了负的。我在每次代码评审时都会检查对应文档是否及时更新,这已经成了团队开发流程的一部分。
6. 常见问题排查与避坑指南
从开发到部署,我记录了不少典型问题,这里挑几个出现频率最高的列出来,附上排查思路和解决方案。
6.1 MyBatis-Plus分页失效问题
很多新手用MyBatis-Plus分页时,会发现自己写selectPage之后返回的total一直是0,或者干脆SQL里没有LIMIT语句。这个问题九成原因是漏掉了分页插件的配置类。MyBatis-Plus需要显式把PaginationInnerInterceptor注册到MyBatis-Plus的Interceptor链上,不是导入依赖就能自动起效的。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(); paginationInterceptor.setDbType(DbType.MYSQL); paginationInterceptor.setMaxLimit(500L); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }setMaxLimit(500L)是防止有人传入pageSize=99999一次性拉全表数据,加上这个限制之后,超过500的请求会被自动截断,有效保护数据库。排查这类问题时,可以打开MyBatis的SQL日志打印,观察实际执行的SQL,就能快速确认有没有拼接LIMIT。
6.2 Vue3响应式丢失的那些常见操作
Vue3里响应式丢失的问题排查起来往往更加隐蔽。我举两个自己踩过的真实场景。
第一种是直接给reactive对象整体赋值:
const form = reactive({ name: '', age: 0 }) // 错误写法 form = { name: '张三', age: 20 } // 正确写法 Object.assign(form, { name: '张三', age: 20 })reactive对象本身是一个Proxy,整体重新赋值会打断原来的代理引用,页面自然无法更新。必须要用Object.assign把新数据铺到原对象上。
第二种是用ref包裹复杂对象但在模板里直接访问内部属性:
const tableData = ref([]) // 给tableData赋值时 tableData.value = response.data模板里使用tableData时其实会自动解包value,但如果有人在setup里直接写tableData.length,得到的会是undefined,因为ref对象的属性不是响应式代理的直接属性。
日常开发中我养成了一个习惯:复杂对象用reactive,基础类型和需要v-model绑定的场景用ref,两种方式不要混用,这样代码会清爽很多,心智负担也小。
6.3 数据库连接池与超时异常处理
项目上线运行一段时间后,最容易出现的问题是“数据库连接超时”或“连接池等待超时”。特别是长时间无人访问时,MySQL默认的wait_timeout是28800秒(8小时),超过这个时间MySQL会主动断开空闲连接。如果Java应用侧的数据库连接池没有及时剔除这些失效连接,再次访问时就会因为拿了坏连接而报错。
我的处理方式是调大HikariCP连接池的max-lifetime并启用keepalive-time:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 idle-timeout: 300000 max-lifetime: 580000 connection-timeout: 30000 keepalive-time: 300000设置逻辑是:max-lifetime设置为580秒(约9.6分钟),略短于MySQL的wait_timeout,连接池会在数据库断掉连接之前主动替换连接。keepalive-time设置为300秒一次心跳检测,确保空闲连接不会被服务端提前断开。只要这个配置配合到位,线上基本不会出现“睡死”问题。
7. 这套系统的可扩展方向
当前版本已经能完整支撑校园健康驿站的基本业务,但结合实际使用后的反馈,还有几个明确的可扩展方向值得提前考虑。
第一个方向是数据统计与可视化。现在系统里积累了大量健康上报数据和异常记录,如果能接入图表展示,比如按院系统计上报率、按时间展示体温异常数量趋势,对管理决策的价值会大幅提升。目前后端的聚合查询已经预留了部分接口,前端可在ECharts或AntV G2中选型实现这部分能力。
第二个方向是消息通知能力。目前的风险提示(异常预警、审批进度)都停留在系统内部待办列表,如果能对接邮件或短信网关,将通知主动推送给相关责任人,闭环响应速度会更快。这部分建议引入消息队列,避免直接同步调用外部接口导致核心链路变慢。
第三个方向是数据归档策略。健康数据属于较敏感的个人信息,长期保存在业务表里会让表越来越大,查询越来越慢。建议增加归档策略:超过6个月的已结束记录自动迁移到归档表或者冷存储,满足数据合规要求的同时保持业务表轻盈。
我个人的体会是,校园管理系统这类项目的核心价值并不在于用了多前沿的技术,而在于是否真正贴合用户的使用习惯、是否把数据链条打通、是否能在后期持续迭代。这套健康驿站系统从需求梳理到部署上线,整体节奏比较紧凑,但由于前期把表结构、权限模型、接口规范都定得比较清楚,实际开发周期比预估缩短了大约三分之一。如果你正在做类似的前后端分离项目,可以参考文中提到的这些设计决策和避坑细节,尤其是数据库时间时区、前端响应式陷阱、部署时的Nginx配置这三个环节,提前处理好,后续能省掉大量排查时间。