1. 赛事保障里的"冷板凳"环节:这套系统到底在解决什么
办一场全国性的少数民族运动会,大家盯的都是开幕式、赛程、场馆、接待这些台前工作,但有一块后台任务特别容易被低估,那就是赛前的网络安全培训。运动员、教练、随队工作人员、志愿者、媒体记者,上千号人在一个时间段内集中入场,各自带着手机、笔记本、工作终端,一旦有人把内部赛程表传到不明平台,或者连了伪造的场馆Wi-Fi,影响的是整个赛事的正常运行。
以前这类培训基本靠线下讲座加纸质考核。负责安保信息化的同事把几百页PPT往会议室一放,各代表团派代表参加,签个到就结束,剩下的人只能拿到一页纸质学习资料。考核更是走过场,答完卷子没人统计谁没过,更不知道哪个知识点大家普遍没掌握。这种模式的问题在于:它只做了"告知",没有做"验证",更谈不上"针对性补强"。
所以我做这个基于Spring Boot + Vue的网络安全学习系统时,第一诉求不是"做个在线考试网站",而是把赛事场景下的安全教育变成一条可追踪、可量化的闭环链路。系统要能承载三类核心任务:
- 分层学习:运动员、教练、志愿者、技术保障人员、媒体人员对网络安全知识的侧重点不同,不能所有人学同一套课程。
- 强制考核与补考机制:赛前必须完成规定课时并通过测试,未通过的要能自动触发补考提醒,直到达标。
- 数据留痕:谁学了、学了多久、考了多少分、错在哪类题目上,这些数据要沉淀下来,供赛事安保部门在入场前做风险评估。
这套系统本质上是一个面向特定人群、带强考核属性、以赛事节点为生命周期的专用学习平台。它跟通用在线教育系统的差别在于:用户是"被迫学习"的,学习结果有硬性关卡属性,而且使用周期只有赛前几周。这意味着系统设计必须降低使用门槛,路径要极简,后台统计必须直接服务于现场管理,不能花里胡哨。
适合参考这套设计的朋友,一个是做各类行业培训系统的开发者,一个是承接赛事、大型活动信息化保障项目的团队。Spring Boot + Vue这套组合在中小型管理系统里已经是事实标准,但真正值得聊的不是框架本身,而是"赛事化学习系统"这个场景下,从业务建模到细节落地的一整套取舍。
2. 技术选型的真实逻辑:Spring Boot + Vue不是什么万能答案,但在这个场景里够用且稳
很多项目一开始纠结框架选型,其实脱离场景谈技术栈都是空谈。这套系统选Spring Boot + Vue,我主要基于三个维度的权衡。
2.1 终端环境复杂,前后端分离是必然选择
运动会参与者的终端极其分散。场馆内的办公电脑可能是Windows 7的老机器,志愿者的手机从千元安卓到最新款iPhone都有,媒体工作间的设备更是五花八门。如果沿用后端模板渲染的老做法,浏览器兼容性、页面渲染压力、接口并发全部搅在一起,后面调试会非常痛苦。
前后端分离后,后端只管提供JSON接口,前端Vue负责页面交互,两者通过明确的API契约协作。老机器跑不动复杂页面,就把Vue构建产物降级到ES5标准,同时避开Canvas动效和重型图表库。移动端用户直接访问同一套Web页面,通过媒体查询做响应式适配,不需要单独开发App,省掉了一整条应用商店审核和安装分发的链路。对于只有几周生命周期的赛事系统来说,Web端是性价比最高的形态。
2.2 后端选Spring Boot,不是因为它新,而是因为它稳
Spring Boot在这个项目里的优势主要是三个:
- 生态成熟,找资料容易:赛事系统工期紧,团队里如果有个把月没写过Java的人,Spring Boot的Starter机制让他能很快上手。项目里用到Spring Security、MyBatis-Plus、Redis、MinIO这些组件,每个都有大量现成案例可以查,踩坑成本低。
- 内嵌容器,部署简单:生产环境直接用
java -jar跑一个可执行Jar包,不需要单独装Tomcat,对现场运维来说少一个故障点。 - 事务与安全体系完善:考试提交成绩、学习时长记录这类操作对数据一致性要求高,Spring的声明式事务注解一把梭,配合Spring Security做接口鉴权,心里踏实。
这里要说明下,如果项目规模大到需要微服务拆分,Spring Boot单体会显得吃力,但一个千级用户的学习系统,单体+缓存绰绰有余。盲目上微服务反而会引入服务发现、配置中心、链路追踪一堆复杂度,不值当。
2.3 为啥不选Node.js后端或纯Python方案
有朋友问为啥不用Node.js写后端,前后端统一用JavaScript,或者干脆用Python的Django。我的观点是,这套系统的难点不在并发吞吐,而在业务规则的完整性和与赛事现有系统的对接。
赛事安保部门的信息化体系通常基于Java技术栈,身份认证、人员名单、场馆权限这些数据要从已有系统同步过来。后端用Spring Boot,对接时无论是直接读库还是调WebService接口,技术栈一致能省很多沟通成本。Python更适合快速原型,但团队后续维护和知识传递上,Java的规范性对这类"项目结束就要交接"的系统更友好。
2.4 一个容易忽略的选型细节:数据库和缓存
数据库我选了MySQL 8.x,表结构设计上基本是标准范式加少量冗余字段。Redis在这里承担三个职责:存储登录态Token、缓存课程类热点数据、支撑考试防作弊的切屏次数计数。
Redis使用在这类系统里特别容易犯的错是:把所有数据都往缓存里塞。我的做法是只缓存两类数据——不常变化的配置项(如课程分类、赛事公告)和高频读取的热点数据(如首页课程进度卡片)。用户答题记录、成绩明细这类写多读少的数据直接落库,避免缓存和数据库的一致性冲突。这套取舍在后面压测时证明是合理的,读多写少的场景下QPS很稳定。
3. 从课程到考核的完整闭环:五个核心模块的落地细节
这类系统的核心业务链是"课程学习 → 课时达标 → 在线考核 → 成绩判定 → 补考/通过"。每个环节都有隐藏的细节,光看标题觉得简单,实际做起来有不少坑。
3.1 课程内容管理:知识结构化比视频播放更重要
课程模块如果只做"上架几个视频,用户看完算学完",那就把问题想浅了。赛事场景下,用户可能只有碎片时间学习,更需要的是"章节化、可定位进度"的内容结构。
我把课程设计成四级结构:"课程 → 章节 → 知识点 → 学习材料"。每门课程(如《运动员个人信息保护指南》)拆成若干章节,每章下挂3-8个知识点,每个知识点可以绑定一个视频、一篇图文或一份PDF。
这样设计有三个好处:
- 学习进度可以被精细记录:用户看完一个知识点,系统就能精确记录"第2章第3节已完成",而不是笼统记"看过这个课程"。
- 考核可以和知识点关联:每道试题可以标注关联的知识点ID,考试结束后能统计出"哪几个知识点错误率最高",这个数据直接指导后续是否要推送补充学习材料。
- 移动端加载友好:单个知识点的视频控制在5-10分钟,文件小,弱网环境下也能顺畅加载。整门课一小时多的视频拆成多个小段,用户随时暂停随时接着学。
素材存储我用MinIO搭了个私有对象存储服务,视频文件分桶存放,配合Nginx做访问限速,防止现场有人把课程视频地址到处转发造成流量浪费。
3.2 学习进度追踪:防刷课时的"温和手段"与"硬核手段"
既然有考核门槛,就必然有人想走捷径。最原始的做法是挂机刷课时。我在设计学习记录模块时做了三层防刷。
第一层,视频播放心跳上报。前端播放器每隔15秒向后端上报一次当前播放位置和累计观看时长,后端只在两次心跳间隔合理(比如不超过30秒)、且播放位置是递增的情况下才累加学习时长。单纯把视频打开静音放那儿,播放位置不动,后端根本不会计入有效时长。
第二层,知识点完成判定。一个知识点要标记为"已完成",需要同时满足三个条件:视频播放到达结尾或阅读时长超过材料总字数的估算阅读时间、章节内所有知识点均已浏览、没有异常的快进行为。这个判断放在后端做,前端传什么参数都不直接信任。
第三层,后台异常标记。如果系统检测到某个用户单日学习时长超过12小时,或者凌晨3点到5点之间有密集学习记录,会自动给管理端打一个"存疑"标记。管理员查看后可以一键重置该用户的学习进度并要求重新学习。
这里的原则是:学习系统是辅助工具,不能搞得像刑侦系统,所以三层机制里前两层是无声的,第三层才需要人工介入,尽量避免误伤真正认真学习的用户。
3.3 题库设计与随机组卷:知识点的权重比随机更关键
题库模块如果只是"塞几百道题,考试时随机抽几十道",考出来的成绩参考价值很有限。我设计题库时给每道题增加了一个"知识点关联"字段和一个"难度系数"字段,组卷算法按知识点权重分配题目。
比如《网络安全法律法规》课程,知识点权重设置如下表:
| 知识点分类 | 建议题量占比 | 说明 |
|---|---|---|
| 个人信息保护法核心条款 | 30% | 运动员和工作人员最常踩雷的领域 |
| 钓鱼邮件与恶意链接识别 | 25% | 赛事期间高发威胁 |
| 公共Wi-Fi与移动设备安全 | 20% | 场馆场景高度相关 |
| 赛事内部信息保密要求 | 15% | 涉及赛程、战术、健康信息 |
| 应急响应与上报流程 | 10% | 出事以后知道找谁 |
组卷时,系统先从每个知识点分类里按难度比例抽题,再整体随机打乱题序和选项顺序。这样每个人拿到的卷子内容不同、但考察的知识面结构一致,成绩横向可比性比纯随机抽题强得多。
选择题选项乱序我用了一个简单方案:题目表里存选项时不是存A/B/C/D文本,而是存一个JSON数组,考试时由后端在返回试卷前对数组做一次洗牌,同时在缓存里记录正确选项的新位置。这里有个坑是洗牌必须在试卷下发时一次性完成并保存快照,否则用户在考试中刷新页面,选项顺序变了,答案位置对不上,会引发严重投诉。
3.4 在线考核与成绩判定:从交卷到出分的完整链路
考试流程我用状态机来管理,避免前端页面刷新导致状态混乱。状态流转包括:待考试 → 考试中 → 已交卷 → 判分中 → 成绩已发布 → 需补考。
关键点是考试中途刷新页面或意外退出的处理。我的方案是:用户点击"开始考试"时,后端生成一份试卷快照存入Redis,同时开启一个倒计时任务。用户每一题作答后立即将答案写入Redis的Hash结构,而不是等到最后统一提交。这样即使中途浏览器崩溃,重新进入时系统从Redis恢复答题状态,已经作答的题目不会丢。
交卷时,后端从Redis取出作答记录,在数据库事务里完成三件事:写入考试记录表、计算得分、更新用户考核状态。判分逻辑在服务端完成,绝不信任前端计算的分数字段,防止有人通过篡改请求参数刷分。
补考逻辑写成了自动化规则:未通过(低于60分)的用户自动获得一次补考资格,补考卷从另一个题库池里抽取,避免出现原题照抄。补考仍未通过的,系统生成名单推送给管理员,由线下安保部门人工跟进。整个流程从考试到出分控制在5秒内,用户体感是"交卷即出分"。
3.5 后台管理端:不是简单的增删改查
管理端如果只做课程和题库的CRUD,那还停留在外包系统水平。我按使用角色拆成三个视角:
- 系统管理员:管理用户账号、角色权限、系统配置、数据备份。
- 培训负责人:管理课程、题库、考试场次、查看学习进度和成绩报表。
- 代表团联络员:只查看本代表团成员的学习和考核情况,方便线下督促。
这个权限拆分特别重要。赛事场景下,代表团联络员不希望看到其他队伍的数据,这种数据隔离如果用前端菜单隐藏实现,等于没做。我在后端接口层面做了数据权限过滤:联络员角色查询用户列表时,SQL强制拼接所属代表团条件,前端就算手动调接口也拿不到越权数据。
4. 踩坑实录:文档里查不到的四类问题
这部分是实际开发中耗费最多精力的地方。按框架文档做项目基本都能跑通,但真实环境里那些"理论上不该出问题"的地方,才是拖慢进度的元凶。
4.1 视频播放组件在老旧浏览器上的"花屏"与"白屏"
项目里的培训视频是由赛事宣传部门提供的,原始格式五花八门,有MP4、有MOV、甚至还有FLV的老素材。统一转码成H.264编码的MP4后,我在开发环境测试一切正常,但部署到现场试运行时,有几台Windows 7的老电脑打开视频直接白屏。
排查过程花了两天。前端用的是Vue集成了一个开源播放器组件,默认会尝试使用HTML5的video标签播放,但在老浏览器上video标签对H.264的支持不稳定。组件的降级策略是自动切换到Flash播放器,而Flash在现代浏览器上已经被禁用,导致双端失灵。
最终解决方案是:放弃在组件层面做兼容,改为根据浏览器能力动态选择渲染方案。后端在接口里返回视频地址的同时,附带一份"可选清晰度列表",前端先检测浏览器是否支持video标签的canPlayType('video/mp4; codecs="avc1.42E01E"'),支持则走HTML5播放器,不支持则引导用户下载一个指定版本的第三方播放器客户端,在客户端里播放内部培训视频。这在赛事现场虽然多了一步操作,但胜在稳定,比在浏览器里反复试错可靠。
4.2 并发提交学习时长导致的数据覆盖
学习进度上报接口最初的实现很天真:前端每15秒上报一次进度,后端直接UPDATE学习记录表。结果线上数据出现了"学了2小时,进度反而回退到40分钟"的诡异现象。
根因是两个浏览器标签页同时开着同一课程,两个页面的心跳上报交错写入,后上报的旧进度覆盖了新进度。数据库层面没有做版本控制,最后写的那条反而把进度拉回去了。
修复方案是给学习记录表增加一个version字段,每次更新时用乐观锁控制:UPDATE learn_record SET progress = ?, version = version + 1 WHERE id = ? AND version = ?。冲突时丢弃较小进度的请求,同时前端在多个标签页监听storage事件,检测到同账号在其他页签学习时给出提示,让用户只保留一个学习页签。这套组合下来,线上再没出现过进度回退的投诉。
4.3 随机抽题SQL把数据库CPU打满
考试模块刚上线时用的是网上最常见的随机抽题写法:SELECT * FROM question WHERE category_id = ? ORDER BY RAND() LIMIT 20。题库数据量到3000条时还不明显,等我把试题扩充到8000多题、模拟50人同时在线考试时,数据库CPU直接飙到90%,每场考试抽题耗时超过3秒。
ORDER BY RAND()会让MySQL对全表每一行生成随机数再排序,数据量一大就成了性能灾难。我当时改成了两步走:
- 先从
question表查出符合条件的主键ID集合。 - 在应用内存里用
Collections.shuffle()打乱ID列表,取前N个作为本场考试的题目ID,再按ID批量查题目详情。
因为一个用户一场考试只抽50题,内存里打乱几千个ID的操作耗时毫秒级,数据库压力瞬间降下来了。这算是一个很典型的"看着SQL很简单,实际性能很糟"的案例,凡是做题库类系统的人都值得记一笔。
4.3 跨域配置里藏着安全隐患
前后端分离后,跨域是躲不开的问题。我最初在Spring Security配置里写了allowedOrigins("*")配合allowCredentials(true),本地开发一切正常,一到部署环境,浏览器直接报跨域请求被拦截。
查了半天发现是Cookie的SameSite属性在作祟。允许携带凭证的跨域请求,不能同时设置通配的来源。我把前端域名收窄成具体的几个线上地址(比如赛事官方域名下的子路径),并在Nginx层加了CORS响应头。这样既保证了线上请求能正常通过,又避免了*配合Credentials带来的安全风险。
顺带说一句:安全扫描报告里如果出现"CORS配置过于宽松"这种高危项,大概率就是allowedOrigins("*")加allowCredentials(true)组合导致的,评审会上被问到时特别尴尬。
5. 表设计与接口约定:骨架搭对了,后面前后端联调能少吵十次架
这套系统的核心表有9张,我按业务域拆成三组。设计时坚持两个原则:主键一律用自增ID方便上下游对账,所有业务表必须带创建时间和更新时间字段,方便排查数据问题。
5.1 核心表结构速览
| 分组 | 表名 | 关键字段 | 说明 |
|---|---|---|---|
| 用户域 | sys_user | id, username, password, real_name, delegation_id, user_type, status | user_type区分运动员/教练/志愿者/工作人员/管理员 |
| 用户域 | sys_role | id, role_code, role_name | 角色与菜单权限关联 |
| 内容域 | course | id, title, cover_url, category_id, required_hours, status | 课程的必修学时 |
| 内容域 | lesson | id, course_id, title, sort_no, material_type, material_url | 章节与学习材料 |
| 内容域 | question | id, course_id, knowledge_point_id, question_type, content, options_json, answer, difficulty, analysis | 答题的选项用JSON存 |
| 学习域 | learn_record | id, user_id, lesson_id, total_duration, current_progress, version, status | 学习进度记录 |
| 学习域 | exam_paper | id, user_id, course_id, question_ids_json, status, start_time, submit_time, score | 试卷快照存JSON |
| 学习域 | exam_answer | id, paper_id, question_id, user_answer, is_correct | 每题作答明细 |
| 学习域 | exam_record | id, user_id, paper_id, score, pass_flag, attempt_count, exam_type | 考试汇总记录,exam_type区分正式考/补考 |
其中question表的options_json字段存储的是一个JSON数组,比如["选项A文本","选项B文本","选项C文本","选项D文本"],正确选项在answer字段里存的是数组下标。组卷洗牌时,后端只需要把这个数组顺序打乱,同时在内存里记下正确选项的新下标,序列化进试卷快照即可。这种设计比传统的A/B/C/D字段灵活得多,往后再加E选项、F选项不用改表结构。
5.2 鉴权设计:JWT + Redis黑名单
登录模块用Spring Security + JWT实现。用户登录成功后后端签发一个有效期12小时的Token,同时把Token的jti(唯一标识)存入Redis。每次请求进来,过滤器先解析Token,再去Redis查一下这个jti是否存在,存在才放行。
有些团队做JWT登录后,退出登录只是前端删掉Token,后端完全不知道这个Token已经失效。如果Token泄露,攻击者可以在有效期内一直使用。我的方案是调退出接口时把jti写入Redis黑名单,TTL设成Token剩下的有效时长。这样即使Token还在手里,Redis里查不到对应记录,照样拦截。一套几十行的逻辑,把"退出登录"从纯前端行为变成了后端可控行为。
5.3 一个关键接口的幂等设计:学习进度上报
学习进度上报接口是高频接口,要重点保证幂等。请求体设计如下:
{ "userId": 1024, "lessonId": 58, "positionSeconds": 320, "durationSeconds": 15, "clientRequestId": "a3f2c1d9-8b7e-4f2a-9c1d-6e5f4a3b2c1d" }clientRequestId是前端每次上报生成的UUID,后端Redis里以这个ID作为Key存一份处理记录,重复请求直接返回上次结果。这个设计在网络抖动时特别有用,前端超时重试不用担心造成重复累加。
上报逻辑写成一个事务方法,核心代码如下:
@Transactional(rollbackFor = Exception.class) public void reportLearningProgress(LearnProgressRequest request) { String key = "learn:report:" + request.getClientRequestId(); Boolean firstCall = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES); if (!firstCall) { return; // 重复请求,直接忽略 } LearnRecord record = learnRecordMapper.selectByUserAndLesson( request.getUserId(), request.getLessonId()); if (record == null) { record = new LearnRecord(); record.setUserId(request.getUserId()); record.setLessonId(request.getLessonId()); record.setTotalDuration(request.getDurationSeconds()); record.setCurrentProgress(request.getPositionSeconds()); record.setVersion(1); learnRecordMapper.insert(record); return; } // 乐观锁更新:只允许进度递增的请求生效 if (request.getPositionSeconds() > record.getCurrentProgress()) { int rows = learnRecordMapper.updateProgressWithVersion( record.getId(), request.getPositionSeconds(), record.getTotalDuration() + request.getDurationSeconds(), record.getVersion()); if (rows == 0) { log.warn("乐观锁冲突: userId={}, lessonId={}", request.getUserId(), request.getLessonId()); } } }这里还有一个经验是:进度上报接口不要返回200和明确错误码以外的东西,前端只需要知道"上报成功"或"上报失败",不需要知道具体进度值。详细的进度查询单独提供一个查询接口,把上报和查询解耦,避免接口职责膨胀。
6. 部署与安全加固:线上跑起来的"最后一公里"
系统开发完只是第一步,真正考验人的是部署联调阶段。赛事场馆的网络环境比想象中复杂,多网隔离、有限带宽、固定IP限制,任何一个环节没考虑到,现场就是一场事故。
6.1 Nginx配置的细节坑
我用Nginx做了静态资源服务和一个反向代理入口。有两个配置细节特别值得提醒。
一是上传文件大小限制。课程视频和PDF动辄几十上百MB,Nginx默认client_max_body_size只有1MB,管理端上传视频直接报413错误。必须在server块里显式设置:
client_max_body_size 200m;二是代理超时时间。考试交卷接口虽然设计成快速返回,但成绩计算里带了几次数据库查询和Redis操作,加上现场网络可能有波动,Nginx默认的60秒超时在极端情况下还是会触发504。因此考试相关的location单独设置:
location /api/exam/ { proxy_connect_timeout 10s; proxy_read_timeout 120s; proxy_send_timeout 60s; }6.2 接口防刷与数据脱敏
赛事期间系统访问量会集中在某些时间点(比如晚上培训集中学习时段),防刷不能只靠登录鉴权。我在网关层加了一个简单的接口限流:基于Redis的固定窗口计数器,每个用户每分钟最大请求数限制为120次,超过直接返回429。这个阈值是压测出来的,正常用户学习+考试每分钟请求量不超过60次,120次留有足够余量,又不会影响正常体验。
另外个人信息字段的脱敏也要注意。管理人员查询用户列表时,手机号显示为138****1234,证件号码只显示后四位。这个脱敏在后端序列化时统一处理,而不是前端做字符串截取。否则前端源码里能看到完整数据,等于脱了个寂寞。
6.3 考试场景的防作弊手段
在线考试无法完全杜绝作弊,但在赛事内部培训场景下,核心目标是防止"明显且随意的作弊",所以我的策略是"前端威慑+后端留痕"。
前端在考试页面监听visibilitychange事件,用户切出浏览器标签页时记录一次"切屏行为",切屏超过3次自动交卷。后端同时记录每次切屏的时间戳,保存到考试记录里备查。
这里有个技术要点:前端的切屏检测只是辅助,不能作为判分依据。有些考生用两台设备,一台考试一台查资料,前端根本检测不到。所以更有效的是后端在交卷时校验答题节奏:如果某考生50道题全部在10秒内完成,系统自动标记"疑似异常交卷",由管理员进入人工复核流程。这套"技术威慑+数据异常检测"的组合,在实际使用中基本没人敢肆无忌惮地乱来。
7. 写在最后:做完这个项目的一些真实体会
这几周项目做下来,我最大的感触是:学习考试类系统的难点从来不在功能实现,而在业务规则的完整性和异常场景的兜底。框架选型、接口设计、表结构这些事情都有现成答案可抄,用户也不会关心你用的什么版本、什么架构,他们只关心"我学完了到底算不算数""我考挂了能不能补考""我学了半天进度丢了找谁"。
这里说个体会最深的点:做这类带强考核属性的系统,一定要把"规则"和"数据"分开管理。规则是"及格线60分""必修学时120分钟""补考次数1次",这些最好做成数据库配置表,而不是硬编码在代码里。现场运行过程中,培训负责人随时可能调整这些阈值,比如赛程压缩了,学时要求从120分钟降到90分钟,如果硬编码,就得重新发版;做成配置,管理后台改个数字立即生效。
另外一个建议是:上线前做一次"最笨用户"的走查。找一位完全没接触过系统的同事,不给他任何指引,让他自己去完成"注册 → 学习 → 考试"的全流程。你会发现大量想当然的问题:按钮文案不够清楚、验证码看不清、学习完成后没有明确提示。这些问题用户不会主动反馈,他们只会默默地不用你的系统。
最后,给准备做类似系统的朋友一个收尾建议:做项目时,预留20%的时间专门处理数据报表和导出功能。赛事保障领导关心的是"现在还有多少人没完成培训",这个数据如果不能实时、直观地出现在一张大屏或一张Excel表里,系统做得多精美都没用。To B系统或者说赛事支撑系统,数据可视化能力往往比交互动画更重要。