体育老师拿着一沓手写成绩单,对着评分表一个个换算分数,期末再熬两个晚上汇总全班报表——这是很多小学体质测评工作的真实日常。我最近完整做了一套前后端分离的小学生身体素质测评管理系统,技术栈是 SpringBoot + Vue + MyBatis + MySQL,包含了从学生信息管理、测评项目配置、成绩录入与自动评分,到班级对比统计和可视化报表的完整闭环。整套源码和部署文档都整理好了,拿来当课程设计、毕业设计项目,或者给学校内部做体育测评管理工具,都能直接落地。这篇就把我做这个项目的思路、核心代码、部署过程,以及踩过的坑一次性讲清楚。
1. 项目背景与整体架构选择
1.1 这类系统的实际使用场景
做之前我专门找体育老师聊过需求。小学生体测一般包含身高体重、肺活量、50米跑、坐位体前屈、一分钟跳绳等固定项目,每个项目针对不同年级、性别都有对应的评分标准。过去这些数据分散在纸质表格和 Excel 里,老师录入靠手敲,评分靠人工对照,期中、期末要出汇总报告时还得逐项统计。低年级孩子又多,同名、错字、漏录都是家常便饭,期末对账能对到崩溃。
所以这套系统瞄准的就是三个核心问题:一是把学生基础信息和班级结构管起来,二是把测评成绩录入、修改、评分流程标准化,三是把班级、个人的成绩对比和统计报表自动化。权限上做了三种角色:管理员管账号和学生数据,体育老师录入和修改成绩,学生(实际使用中是家长代看)只能查看自己的成绩。这样数据不裸奔,回应用户的登录、查询、统计需求都清晰。
1.2 为什么选前后端分离 + SpringBoot/Vue/MyBatis/MySQL
技术选型时我其实纠结过是搞传统 JSP 单体应用还是前后端分离。最终选前后端分离,理由很务实:第一,前端 Vue 的组件化开发对表单页、列表页、报表页这种界面密集场景效率高很多,一个成绩录入页面就是一个可复用的组件;第二,后端只暴露 RESTful API,将来要做微信公众号查询成绩、小程序端,接口可以直接复用,不用重写业务逻辑;第三,前后端分离后前后端同学可以并行开发,后端按接口文档出数据,前端用 mock 数据同步画页面,联调周期短。
技术栈本身也属于主流稳妥组合。SpringBoot 省去了大量 Spring 配置,内嵌 Tomcat,一个 jar 包就能跑;MyBatis 把复杂查询的掌控权留给你,尤其是成绩筛选这种多条件动态查询,比 JPA 的自动 SQL 更直观;MySQL 免费开源,部署环境到处都是,学校机房和云服务器都能低成本搞定。这套组合意味着网上资料多、遇到问题好搜、招人接手也容易,对一个实际要维护的系统来说很重要。
1.3 功能模块和页面流转
系统按功能切成六块:登录鉴权模块、班级与学生信息管理、测评项目与评分标准配置、成绩录入与换算、统计报表、个人中心。页面流转大致是登录后进入工作台,管理员先维护班级和学生,配置测评项目和评分区间;老师选择某个班级、某个测评项目、某次测试日期,进入成绩录入页批量填写原始成绩,保存时自动按性别和年级换算成分数与等级;统计页可以按班级看平均分、优良率,也可以选一个学生看各项目雷达图。这套流程从数据准备到成绩输出是顺的,我用表格把模块和对应职责列一下:
| 模块 | 对应业务 | 涉及角色 |
|---|---|---|
| 登录鉴权 | JWT 签发、校验、角色过滤 | 全部 |
| 班级学生管理 | 班级树、学生增删改查、批量导入 | 管理员 |
| 测评项目配置 | 项目维护、评分区间配置 | 管理员 |
| 成绩录入 | 按班级/项目/日期录入、修改 | 体育老师 |
| 自动评分 | 原始成绩换算得分与等级 | 后端自动完成 |
| 统计报表 | 班级对比、趋势图、个人雷达图 | 管理员、老师、学生 |
2. 数据库设计:核心表和字段细节
2.1 主表结构设计思路
数据库是这套系统能不能扩展的关键,我花的时间也最多。总共设计了六张核心表:用户表、角色表、班级表、学生表、测评项目表、成绩记录表,另加一张评分标准区间表。用户表和角色表做了最简单的多对多关联,虽然本项目角色就三种,但用关联表以后加“教务管理员”这类角色不用改表结构。
班级表和学生表是典型的父子关系,学生表里存class_id外键,再加上姓名、性别、出生日期、学籍号几个基础字段。这里要特别注意,评分依据是“性别 + 年龄(实际按年级)”双维度,所以出生日期必须准确录入,年级信息放班级表而不是学生表,因为班级天然对应年级,查起来更直观。
成绩记录表是核心,我贴一下建表 SQL:
CREATE TABLE fitness_record ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL COMMENT '学生ID', item_id INT NOT NULL COMMENT '测评项目ID', score_value DECIMAL(6,2) COMMENT '原始成绩,如50米跑10.20秒', score INT COMMENT '换算后的得分(0-100)', result_level VARCHAR(10) COMMENT '优秀/良好/及格/不及格', test_date DATE NOT NULL COMMENT '测试日期', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_item_date (student_id, item_id, test_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='身体素质测评成绩记录表';UNIQUE KEY uk_student_item_date是我特意加的,防止同一天同一个学生对同一个项目录了两条记录,这在多人轮流录入时特别容易发生。score_value存原始成绩,score存换算后的分数,两者分开存,后面改评分标准重算成绩时不用丢原始数据。
2.2 评分标准怎么设计成可配置
小学生体测评分标准有个特点:不同年级、男生女生,同一项目的及格线、满分线都不一样。如果把这些规则硬编码到 Java 里,一旦学校拿到新评分标准,就得改代码重新部署,很不灵活。
我的做法是单独建一张item_standard区间表,字段包括项目 ID、性别、年级、低值、高值、对应得分。比如 50 米跑男生五年级,10.0 秒到 10.4 秒之间记 85 分,那就存一条区间记录。换算时用 SQL 的BETWEEN或者内存里的区间二分查找都能快速算分。这样做的好处有两个:评分规则调整时直接在管理后台改数据,不用动代码;不同地区学校如果采用不同的地方标准,导入自己的区间表就能适配。
另外,所有业务表我都加了deleted逻辑删除标记和create_time、update_time字段。逻辑删除对这类有历史成绩的数据特别重要,误删学生后成绩记录还能追溯,审计对账时也说得清。
2.3 统计查询的表关联设计
统计报表大多要跨表查询:成绩记录关联学生表拿姓名班级,再关联班级表拿班级名称,还要关联项目表知道是什么项目。为了不让统计 SQL 写得又长又绕,我在学生表和班级表上都建了索引,成绩记录表对item_id、test_date建联合索引。实测下来几千条成绩数据的统计接口响应基本都在几十毫秒内,小学一个年级几百个学生,这个量级完全无压力。
3. SpringBoot 后端核心实现
3.1 工程结构与统一返回体
后端工程按标准分包:controller、service、mapper、entity、common、config。common里放统一返回类、异常处理器、JWT 工具类。统一返回体我封装成了Result<T>,所有接口返回{ code, message, data }三段结构,前端拦截器只需要判断code是不是 200,不用每个接口单独写错误判断。
全局异常处理也在这个工程里体现得很重要。业务异常、参数校验异常、数据库异常各写一个处理方法,统一转成标准返回。否则 MyBatis 抛一个 SQL 语法异常直接透传到前端,页面就是一片空白加一堆看不懂的英文,用户体感很差。
3.2 JWT 登录鉴权怎么落地
登录接口接收用户名密码,密码用 BCrypt 加密存储,比对通过后用 jjwt 库签发一个 token,token 里带上用户 ID 和角色信息,设置 24 小时过期。前端把 token 存到 localStorage,每次请求在Authorization头里带上。后端用拦截器统一校验:没有 token 或者 token 过期直接返回 401,前端拿到 401 就跳转登录页。
有几点实际经验值得说说。第一,token 里不要放敏感信息,用户姓名、手机号之类的一律不放;第二,角色校验我是在拦截器里拿到 token 里的角色串,再对特定接口做白名单判断,比如成绩录入接口只有老师角色能进;第三,密码加解密工具我单独封装了一层,以后要换加密算法只改一个类。这套方案没有引入完整的 Spring Security,项目里几十个接口够用,代码还更直白,新手接手也容易。
3.3 MyBatis 动态 SQL 处理多条件筛选
成绩查询是最典型的动态条件场景:按学生姓名模糊查、按项目查、按日期范围查、按班级查,这些条件任意组合。用注解写 SQL 只能写死条件,所以我选择了 XML 映射文件里的<where>+<if>动态 SQL:
<select id="selectRecordList" resultType="com.demo.fitness.entity.FitnessRecord"> SELECT * FROM fitness_record <where> <if test="studentName != null and studentName != ''"> AND student_id IN (SELECT id FROM student_info WHERE student_name LIKE CONCAT('%', #{studentName}, '%')) </if> <if test="itemId != null"> AND item_id = #{itemId} </if> <if test="testDate != null"> AND test_date = #{testDate} </if> </where> ORDER BY test_date DESC, id DESC </select><where>标签会自动处理第一个条件前面的AND,这是我最初容易踩坑的地方,如果直接写WHERE 1=1再加AND,虽然能跑但 SQL 不干净。分页我用 PageHelper,一行PageHelper.startPage(pageNum, pageSize)就搞定,底层自动拼接LIMIT语句,列表接口省了很多样板代码。
3.4 自动评分和统计报表接口
自动评分接口接收学生、项目、原始成绩三个要素,先根据学生性别和年级查评分区间表,再比对score_value落在哪个区间,得出分数和等级,写入成绩记录。这里有个细节:不同项目“数值越大越好”还是“数值越小越好”是反的,比如 50 米跑时间越短分越高,跳绳次数越大分越高。所以区间表里加了direction字段区分正向和反向,换算逻辑统一按方向判断,避免每个项目写一套。
班级统计接口给报表页提供数据:选一个项目和一次测试日期,按班级分组算平均分、优良率。核心 SQL 用CASE WHEN统计优良人数:
SELECT c.class_name, COUNT(r.id) AS total_count, SUM(CASE WHEN r.score >= 85 THEN 1 ELSE 0 END) AS excellent_count, ROUND(AVG(r.score), 2) AS avg_score FROM fitness_record r LEFT JOIN student_info s ON r.student_id = s.id LEFT JOIN class_info c ON s.class_id = c.id WHERE r.item_id = #{itemId} AND r.test_date = #{testDate} GROUP BY c.id这类统计 SQL 我放在 Mapper XML 里而不是 Java 里拼,因为数据库做聚合比把数据拉到内存再算高效得多,代码也更简洁。
4. Vue 前端落地细节
4.1 路由和权限控制
前端我用的 Vue 2 + Element UI 这套成熟组合,工程结构是常规的views、components、router、api、utils分层。路由分为公开路由和需要登录的路由,登录页放/login,其他页面统一挂在Layout布局组件下,侧边菜单按角色动态渲染:管理员看到班级学生管理和项目配置菜单,老师看到成绩录入和统计报表,学生只有个人成绩查询。
路由守卫是前端权限控制的关键。beforeEach里判断有没有 token,没有就跳登录页;跳登录页时带上redirect参数,登录成功后回到原来想去的页面。这个细节很多项目没做,用户登录后还得自己再点一遍菜单,体验差不少。
4.2 axios 封装和跨域调试
axios 我封装成一个统一实例,设置了baseURL: '/api',请求拦截器从 localStorage 拿 token 塞进请求头,响应拦截器统一处理 401 跳转和业务错误提示。这样每个页面调接口只需要关注业务数据,不用重复处理登录过期。
开发环境跨域用 Vue 脚手架自带的 proxy 解决,vue.config.js里把/api前缀的请求代理到后端 8080:
module.exports = { devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这里我把前端开发端口改成 3000,避免和后端 8080 撞车。前端用 3000 端口访问,请求/api/xxx时开发服务器把请求转发到 8080,浏览器同源策略不会拦截,因为前端看到的始终是 3000 端口。
4.3 表单校验和成绩录入的交互细节
成绩录入页面是前端交互最复杂的部分。我的做法是按班级展示一个可编辑表格,一列是学生姓名,后面每个测评项目跟一个输入框。Element UI 的el-input绑定每行数据,输入失焦时立即调用评分接口换算分数,当前单元格自动显示换算结果,老师能马上看到哪个孩子不及格,当场就能复核。
表单校验上,原始成绩限定数字和小数点,范围按项目设置最小值和最大值。这个范围校验很重要,去年有个老师把 50 米跑成绩 9.8 分钟当成 9.8 秒录进去,没有范围校验直接存成“优秀”了,后面核对时才发现。我在前端和后端都做了范围校验,防止脏数据进库。
4.4 ECharts 统计报表的接入
统计报表选 ECharts,按需引入核心包和柱状图、折线图、雷达图,不要全量引入,否则打包体积会大不少。班级对比用柱状图展示平均分,个人历史成绩用折线图按测试日期展示变化,学生五项素质能力用雷达图一眼看出哪个项目是短板。ECharts 初始化时注意组件销毁后要调用dispose释放实例,否则切换路由多了会内存泄漏,页面卡顿,这是做图表页最容易忽略的问题。
5. 本地开发到服务器部署全流程
5.1 环境准备
部署前把环境理清楚能省很多排查时间。后端需要 JDK 1.8 或 11、Maven 3.6+;前端需要 Node.js 14/16 和 npm;数据库 MySQL 5.7 或 8.0 都行,但要注意 8.0 的驱动类名和时区参数和 5.7 不一样;服务器上装 Nginx 做静态资源托管和反向代理。我建议本地先跑通,再上服务器,别一上来就在服务器上折腾,排查问题成本高得多。
5.2 后端打包和启动
后端打包执行:
mvn clean package -DskipTests java -jar fitness-system.jarapplication.yml里数据源配置是我经常帮别人排查的重点:
spring: datasource: url: jdbc:mysql://localhost:3306/fitness_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: fitness_user password: your_passwordserverTimezone=Asia/Shanghai没有的话,本地时间没问题,部署到云服务器会报时间差 8 小时的错;useSSL=false是因为本地开发通常不配 SSL 证书,手动连会告警。MySQL 8.0 还要注意驱动用com.mysql.cj.jdbc.Driver。
5.3 前端构建和 Nginx 发布
前端构建产出静态文件:
npm install npm run build构建产物在dist目录,上传到服务器后让 Nginx 托管。我的 Nginx 关键配置是这样:
server { listen 80; server_name _; root /usr/share/nginx/html/dist; index 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 $uri $uri/ /index.html; } }两个关键点:一是/api/开头的请求反代到后端 8080,这样生产环境还是走同一个域名,不存在跨域问题;二是try_files $uri $uri/ /index.html解决前端路由刷新 404,否则 Vue 的 history 模式路由一刷新就白屏。
5.4 数据库初始化和数据迁移
上线前把数据库脚本按顺序执行:先建库建用户,再建表,最后初始化评分标准数据。我习惯把 SQL 拆成schema.sql和data.sql两个文件,结构数据和业务初始数据分开。数据库账号不要直接用 root,单独创建一个只对这个库有权限的用户,即使被注入也影响不了其他库。备份策略上,测试系统要求不高,每天凌晨 mysqldump 一次全库就够了,但备份脚本一定要记得测试恢复流程,真到要恢复数据那天发现备份文件是坏的,比没备份还难受。
6. 踩坑实录:这些问题我全遇到过
6.1 端口占用和依赖版本冲突
第一次启动后端就报端口被占用,查了才知道本机某个服务把 8080 占了。解决方式两种:改application.yml里的server.port,或者用命令行查占用进程再处理。前端项目尤其容易撞端口,所以我把 Vue 开发端口固定成 3000,避免两边打架。
依赖版本是另一大坑。Vue 2 必须配 Element UI,Vue 3 要配 Element Plus,混着装必报错。我见过太多人把 Element UI 装进 Vue 3 项目,页面出来一堆莫名其妙的渲染问题。还有 jjwt 这个库,0.9.x 和 0.11.x 的 API 差异很大,网上老教程大多是 0.9 的写法,照抄到 0.11 直接编译报错。版本统一用 SpringBoot 父工程管理的,别自己裸奔指定版本。
6.2 MyBatis 找不到 Mapper 和动态 SQL 报错
“Invalid bound statement (not found)”这个报错我几乎每个项目都会遇到一次。原因基本就三个:启动类上的@MapperScan包路径写错,Mapper XML 文件没有放在resources下对应的包路径,或者 XML 里namespace和接口全限定名不一致。排查顺序我建议先看编译产物里有没有 XML 文件,没有就是资源路径问题;有就对比 namespace 和接口路径。
动态 SQL 的坑主要是<if>判断字段类型。比如判断testDate是 null,如果用字符串比较testDate != ''而变量是 LocalDate 类型,会直接类型转换异常。每个<if>的判断条件都要确认变量类型和写法匹配。
6.3 前端刷新 404、接口 404 和 token 失效
前端刷新 404 就是 Nginx 没配try_files,前面已经说过了,这是部署完最容易出现的问题,页面看起来好好的,按一下刷新就白屏,排查起来很懵。接口 404 则通常是路径问题,我遇到过后端配了context-path: /api,前端 axios 的 baseURL 又是/api,结果请求路径变成/api/api/xxx。这种问题看 Nginx 的 access.log 加后端日志一对比就清楚了。
token 失效的细节是前端不能只跳登录页,还要把 localStorage 里的旧 token 清掉,否则用户重新登录前可能会有残留状态。另外 JWT 无状态意味着后端没法主动把某个 token 作废,所以过期时间不能设太长,24 小时对校内系统来说是一个比较平衡的选择。如果学校要求老师能主动踢人下线,那就要引入 Redis 做 token 黑名单,这是后续功能上的一个优化点。
6.4 最后聊点个人体会
做完这套系统最大的体会是:技术本身不是难点,难的是把体育测评这个业务逻辑理顺。评分标准的可配置化、成绩的防重复录入、原始成绩和换算分数分离存储,这些设计回头看来都是因为真正理解了学校场景才做出来的。如果你也想自己从零写一套类似系统,我建议先找一位体育老师聊半小时业务,把数据流程画出来再动手写代码,绝对比直接照着网上模板抄更值得。
后续如果要扩展,我列了几个方向:把家长查询做成小程序端,后端接口已经预留了;成绩报告导出 PDF 发给家长;按学期归档历史数据,做跨学期体质变化趋势。这套基于 SpringBoot + Vue + MyBatis + MySQL 的骨架不管扩什么功能,结构上都能兜得住,这也是当时认真设计数据库和接口规范换来的底气。