智慧校园云端管理系统开发实战:从Spring Boot到高并发部署
简介这份智慧校园云端管理系统设计与实现资料包定位清晰面向高校信息化建设者、课程设计与毕业设计人群贴合Spring Boot后端搭配Vue前端的典型开发场景也适合用来学习前后端分离项目的服务端分层、接口设计与部署方式。压缩包共437个文件整体仅11.36MB。其中38个Java源文件与38个class文件组成后端主体126个xml配置涵盖Maven与MyBatis相关框架映射24个js和20个css支撑页面交互116个png、52个jpg多为界面效果图与素材另有4个json、2个yml等配置文件同时附1个sql数据库脚本、1个xmind架构图、1个pptx演示文稿和1个md实现流程类型覆盖源码、数据库、文档、图表结构清晰便于按需查阅。已有2299人学习说明其作为课设、毕设参考的价值得到不少同学认可。通过源码可查看学生、教师、班级、成绩等模块的Controller与实体设计结合JwtHelper类理解JWT鉴权思路参考Result类了解统一返回封装借助数据库脚本和架构图可快速还原项目环境按md流程可梳理从建库到运行的关键步骤。整体适合用来完成课程设计、毕业设计或作为Spring Boot与Vue实战入门的完整案例。1. 智慧校园云端管理系统从课程设计到生产部署还差什么“智慧校园云端管理系统”这个标题在高校毕设和培训机构项目里出现频率极高但大部分实现停留在“能跑”层面——用 Spring Boot 搭几个 CRUD 接口前端套个后台模板录几个功能模块就算交付。真正要让它从课程设计变成能扛住开学季上千人并发选课和成绩查询的云端服务难点全在看不见的地方领域模型怎么划边界、权限怎么做到按需收敛、JWT 无状态会话如何续期、WebSocket 掉线后如何自动恢复。这篇笔记按我从 0 到 1 实现这套系统的一线路径来写适合正在做毕设的本科生、刚入职想快速上手 Java Web 全栈的开发者以及学校信息中心想自建系统的运维人员。你会先得到一套模块划分方法论再拿到可直接复用的认证、幂等、缓存和实时通信代码最后避开我在集群部署时踩过的坑。2. 拆解领域模型智慧校园的模块边界、权限选型与数据表设计常见的做法是先建菜单学生管理、教师管理、课程管理、成绩管理、宿舍管理……菜单就是模块然后建表、写 Controller。我第一次做智慧校园系统的设计与实现时也是这么干的结果做到第五个模块时发现通知公告要关联到班级考勤要关联到课程表消费记录要关联到学生学籍每张表都和其他表互相拉扯改一处接口要牵连七处。这就是“按菜单划分模块”的坑菜单是入口不是领域边界。2.1 模块边界用“三域划分”代替功能清单堆砌我把整个系统重新定义为三个域。第一个是基础数据域学生、教职工、院系、班级、课程、宿舍、设备这些是“事实”数据由信息中心或教务导入全系统共享。第二个是核心业务域选课、排课、考勤、成绩录入、在线缴费、图书借阅这些是“流程”数据依赖基础数据域是整个系统并发压力最大的部分。第三个是开放增值域通知公告、校园卡余额查询、失物招领、问卷收集这类需求变化快页面多可以直接对接公众号或小程序独立部署也不会拖垮主系统。三个域之间只允许上层依赖下层基础数据域不反向依赖业务域开放域不允许跨过业务域直接写基础域的表。这条规则一立模块边界就清晰了。这类设计思路在常见的设计模式里叫“分层依赖倒置”实际项目里你不一定需要背模式名只要记住一句话改一个域的时候不能被迫改另一个域的接口。落地的可执行步骤我会这么走把所有功能点写到卡片上按“基础数据 / 业务流程 / 前端展示”三堆分类再画出每一堆内部对象之间的耦合关系最后给每个域单独建数据库 schema比如 base_db、biz_db、open_db不共用一张表。这样做的直接收益是后面换掉开放域的某个推送服务完全不影响成绩和选课模块。划分方式耦合程度改需求影响范围适用规模按菜单划分高表之间互相依赖跨模块接口频繁被改演示项目按功能模块划分中业务逻辑纠缠仅模块内部可控中小型系统按三域划分低依赖方向单向单域内改动不影响其他域云端多端接入场景2.2 权限模型选型为什么 RBAC 不够还需要 ABAC 补位智慧校园系统最容易被忽视的是权限。课程设计阶段做的是“登录后可访问所有菜单”真实场景是辅导员只能看到本学院学生的成绩任课教师只能录入自己课程的成绩校领导可以看全校汇总但不能查看单个学生的家庭信息。纯 RBAC 给每个角色配菜单权限很快会发现“本学院”这个限定条件没法表达——同一个“辅导员”角色管理的学生集合完全不同。这里就涉及大数据行、列权限设计的思路角色能决定访问哪个菜单但同一菜单里哪些行可见超出了 RBAC 的职责范围。我采用的方案是 RBAC 为主、ABAC 补位角色决定能进哪个菜单粗粒度属性策略决定在同一菜单里能看到哪些行、改哪些列细粒度。ABAC 策略里最常见的几个属性是用户所属院系、用户角色、资源归属院系、操作时间。以下是一个成绩查询的 ABAC 策略示例用 JSON 存规则时很直观{ effect: allow, actions: [query, export], resource: score:record, conditions: { all: [ {property: user.orgId, operator: sameAs, target: resource.orgId}, {property: user.role, operator: in, target: [teacher, counselor]}, {property: resource.status, operator: in, target: [published, draft]} ] } }这段策略的含义是当用户所属院系与成绩记录所属院系相同且用户角色是教师或辅导员时允许查询和导出该记录。参数说明operator 字段我保留了扩展空间习惯上支持 sameAs、in、between、lt/gt 四类就够用了完全没有必要做成通用规则引擎否则会掉进“用 ABAC 重写整个权限系统”的玄学里。实践结论是先用 RBAC 把菜单权限固化再用一个 ABAC 过滤器只拦截敏感业务成绩、档案、消费明细的查询接口这套组合能覆盖 90% 的校园场景。我一般会在数据库里加三张表角色表、用户角色关联表、策略表策略表直接存上面的 JSON 字符串解析放在应用层不引入额外框架所谓开源实现反而可能增加学习成本。2.3 数据表设计学生成绩实体表的四个关键边界以高频出现的“学生课程成绩实体表设计 MySQL”为例成绩表看起来最简单——学生 ID、课程 ID、分数、学期。但实际统计需求一多就会翻车。我见过把补考成绩直接 UPDATE 覆盖原成绩的表导致学期末排名和奖学金评定数据对不上。设计成绩表时我守四个边界。第一不变式成绩记录一旦提交就不可原地 UPDATE改成绩必须走“异动记录表”insert原记录保留。第二冗余字段score_pct 和 score_grade 同时存百分制分数和五级制等级避免每次统计都要做一次换算等级字段可以在成绩录入后由应用层计算但绝不能等查询时再算。第三复合唯一键student_id course_id semester retake_flag其中 retake_flag 区分正常考试和补考这样一条学生同一学期同一门课的历次成绩都能追溯。第四索引边界学期字段选择性低单独建索引没用查询条件通常是“学期 IN (...) AND 院系 ?”统计类接口直接查主表会拖垮写入我会另建一张成绩汇总表按院系课程学期粒度存统计值每天凌晨跑批更新。DDL 片段如下CREATE TABLE score_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, semester VARCHAR(20) NOT NULL, score_pct DECIMAL(5,2) NOT NULL, score_grade VARCHAR(2) GENERATED ALWAYS AS ( CASE WHEN score_pct 90 THEN 优 WHEN score_pct 80 THEN 良 WHEN score_pct 70 THEN 中 WHEN score_pct 60 THEN 及格 ELSE 不及格 END ) STORED, retake_flag TINYINT NOT NULL DEFAULT 0, submit_by VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course_semester (student_id, course_id, semester, retake_flag), KEY idx_semester_org (semester, student_id) );这段 DDL 的说明score_grade 用了 MySQL 的生成列等级由数据库保证一致复合唯一键可以有效阻挡同一门课重复录成绩的误操作idx_semester_org 故意把 semester 放最左是因为按学期筛选后再联查学生表拿院系过滤比单独给 semester 建索引命中率高很多。注意生成列在不同 MySQL 版本行为有差异8.0 以上用 STORED 没问题5.7 下建议直接在应用层算好再写入避免被数据库版本坑到。表结构定好后下一步就是把这些领域对象落成业务接口。3. Spring Boot 核心服务实现JWT 认证、幂等接口与成绩查询的最小闭环领域模型定好后进入“能跑”的环节。技术栈我选的是 Spring Boot MyBatis-Plus Redis MySQL这套组合在一线项目里最常见招聘要求里写明“掌握 Spring Boot”的岗位基本都认这套技能树。下面三个实现分别解决无状态认证、重复提交、高频查询三个问题。3.1 JWT 无状态认证拦截器设计与过期时间怎么定智慧校园系统在云端部署后用户可能从 Web 端、公众号、App 多个入口登录。如果继续用 Session就得在多个服务实例间同步会话或开启粘性会话维护成本高。选 JWT 是为了让认证信息自带截止时间服务端不存会话状态。我的实现很简单登录接口签发 token一个拦截器在每次请求时校验签名和过期时间。Component public class JwtAuthInterceptor implements HandlerInterceptor { // 仅示意实际必须从环境变量注入不能出现在代码仓库里 private final String secret please-change-me-in-production; // 令牌有效期2小时 private final long expireMillis 2 * 60 * 60 * 1000; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String header request.getHeader(Authorization); if (header null || !header.startsWith(Bearer )) { response.setStatus(401); return false; } try { Claims claims Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes())) .build() .parseClaimsJws(header.substring(7)).getBody(); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (ExpiredJwtException e) { // 前端收到401后应静默走续期接口不能直接跳登录页 response.setStatus(401); return false; } catch (JwtException e) { response.setStatus(401); return false; } } }逻辑说明header 里取 Bearer token解析成功后把 userId 和 role 塞进 request 属性后续 Controller 直接取用不用再查一次用户表。关键参数有两个secret 必须放到配置中心或环境变量里代码里的字面量只是本机开发的占位expireMillis 我设成 2 小时不是拍脑袋——校园场景一次登录的平均会话长度在 30 分钟到 1 小时token 短一点服务端失守后的风险窗口就小一点。JWT 续签怎么做我不用刷新 token 的双 token 机制而是用一个 Redis 白名单记录有效 token过期前半小时内访问自动续期并延长白名单时间这样既保留无状态的好处又解决“用户刚登录两小时就被踢”的体验问题。续签后的新 token 放在响应头 X-Renewed-Token 里前端拦截响应并替换本地存储对业务接口完全无感。3.2 接口幂等性用 Redis Token 方案解决选课重复提交选课接口是整个系统里最容易出现重复提交的战场。学生双击提交按钮、网络超时后重试、前端路由跳转后浏览器重新执行上次请求都会造成同一个人同一门课被选两次。接口幂等性设计的核心不是“前端加按钮禁用”而是服务端要能识别出这次请求是不是重试。我的做法是 Redis Token 方案客户端在提交前先向服务端申请一个幂等 token提交时带着 token 一起发过来服务端收到后先尝试从 Redis 里删除这个 token删除成功才继续执行业务逻辑删除失败说明这次请求已经被处理过直接返回“重复提交”提示。// 申请幂等token的接口每次进入选课页时调用一次 GetMapping(/idem-token) public Result idemToken() { String token UUID.randomUUID().toString().replace(-, ); redisTemplate.opsForValue().set(idem: token, 1, 5, TimeUnit.MINUTES); return Result.ok(token); }申请接口本身很简单但注意过期时间设为 5 分钟太短学生填表慢会误报太长又会堆积垃圾 key。提交接口用 AOP 拦截注解标注哪个接口需要幂等保护核心逻辑如下Component public class IdempotentAspect { Around(annotation(idempotent)) public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable { String token RequestContextHolder.getRequestAttributes() null ? null : ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()) .getRequest().getHeader(Idempotent-Token); if (StringUtils.isBlank(token)) { throw new BizException(缺少幂等token); } String key idem: token; // 删除成功才继续执行业务并发情况下Redis只允许一个删除成功 Boolean deleted redisTemplate.delete(key); if (!Boolean.TRUE.equals(deleted)) { throw new BizException(重复提交请勿多次点击); } return pjp.proceed(); } }逻辑说明核心是 redisTemplate.delete(key) 的原子性——多个并发请求同时到达时Redis 只允许一个 delete 成功天然实现了“只处理一次”。这个方案的代价是每次业务提交前多一次 Redis 请求但对智慧校园这种读多写少、写操作并发也不高的系统成本完全可接受。幂等保护不只用在选课缴费回调、图书预约、离校审批等写操作接口我都会挂上统一用注解解决比每个接口自己写判断要干净得多。3.3 成绩查询接口实现从 Controller 到 Mapper 的完整闭环成绩查询是读多写少的典型。我按三层结构写Controller 只接收参数并调用 ServiceService 处理权限过滤和缓存策略Mapper 负责数据库访问。这样做的原因是权限过滤逻辑可能被多个接口复用不能散落在 Controller 里。RestController RequestMapping(/api/score) public class ScoreController { private final ScoreService scoreService; GetMapping(/list) public Result list(RequestParam String semester, RequestParam(defaultValue 1) int page, RequestParam(defaultValue 20) int size) { Long userId (Long) request.getAttribute(userId); return Result.ok(scoreService.pageQuery(userId, semester, page, size)); } }Controller 里的请求参数用了 RequestParam(defaultValue 1)前端不传页码时按第一页处理这能减少一类“参数缺失 400”的线上告警。Service 层的缓存策略如下Service public class ScoreServiceImpl implements ScoreService { public PageResult pageQuery(Long userId, String semester, int page, int size) { // 先查缓存命中直接返回避免打到数据库 String cacheKey score: userId : semester; Object cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return (PageResult) cached; } // 说明权限过滤在Mapper层通过user_id条件完成对应ABAC策略里的行级限制 ListScoreRecord records scoreMapper.listBySemesterAndUser(userId, semester); PageResult result PageResult.of(records, page, size); redisTemplate.opsForValue().set(cacheKey, result, 10, TimeUnit.MINUTES); return result; } }Service 里的缓存 key 包含 userId 和 semester天然避开了跨用户数据串扰问题。Mapper 接口用注解 SQL 就能实现Mapper public interface ScoreMapper { Select(SELECT * FROM score_record WHERE student_id #{userId} AND semester #{semester}) ListScoreRecord listBySemesterAndUser(Param(userId) Long userId, Param(semester) String semester); }这里有个容易踩的点缓存里的旧数据最长要 10 分钟才失效成绩刚录入的学生立刻查成绩会看到旧值。我在下一章讲怎么用“延迟双删”把这个窗口压到秒级并顺带解决 WebSocket 实时通知的掉线问题。4. 实时通知与缓存WebSocket 心跳机制和读多写少的 Redis 方案部署到云端后两个问题会立刻浮出水面一是通知公告需要实时推送到在线用户二是数据库扛不住高频查询。这里介绍我在实时推送和数据一致性两个方向的具体做法。4.1 WebSocket 心跳机制服务端主动踢掉假连接校园通知推送、离校审批状态更新、宿舍报修进度这些场景用轮询会浪费大量请求WebSocket 是常见做法。但 WebSocket 有一个比普通接口更隐蔽的坑连接假死。学生把笔记本合上带走或者手机息屏后网络被运营商回收TCP 连接还没被系统感知服务端仍然维护着一个“看起来在线”的 session推送消息时既不报错也不送达。我的处理是双向心跳客户端每 30 秒发一个 ping 消息服务端在 60 秒内没收到任何消息就主动关闭这个 session同时从在线表里移除。Component public class SocketSessionRegistry { private final ConcurrentHashMapString, WebSocketSession sessions new ConcurrentHashMap(); private final ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); PostConstruct public void startSweep() { // 每10秒扫描一次踢掉超过60秒无活跃的连接 scheduler.scheduleAtFixedRate(this::sweep, 10, 10, TimeUnit.SECONDS); } public void register(String userId, WebSocketSession session) { sessions.put(userId, session); } private void sweep() { long now System.currentTimeMillis(); sessions.forEach((userId, session) - { if (now - session.getLastActiveTime() 60_000) { try { session.close(CloseStatus.GOING_AWAY); } catch (Exception ignored) {} sessions.remove(userId); } }); } }逻辑说明sessions 用 ConcurrentHashMap 保证并发安全scheduleAtFixedRate 每 10 秒执行一次扫描。这里的三个时间参数需要互相配合心跳间隔 30 秒、超时阈值 60 秒、扫描间隔 10 秒超时阈值是心跳间隔的两倍给网络抖动留余量扫描间隔要小于超时阈值的一半否则会出现“早该踢掉但扫描还没到”的延迟。关键点是用 session.getLastActiveTime() 而不是单独存心跳时间戳因为收发任何消息都会刷新活跃时间避免客户端只发业务消息不发心跳也被误踢。前端浏览器端实现时要特别注意跨浏览器支持老版本 Safari 对 WebSocket 的原生 ping/pong 处理有兼容性差异所以我不依赖浏览器原生心跳而是由前端 JS 定时发送文字心跳消息全平台行为一致。const ws new WebSocket(wss://school.example.com/ws); // 每30秒发送一次心跳时间戳用来在服务端排障时定位链路 let heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping, ts: Date.now() })); } }, 30000); // 页面切到后台再回来时补发一次心跳把休眠期补上 document.addEventListener(visibilitychange, () { if (!document.hidden ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping, ts: Date.now() })); } });这套心跳在校园网环境下跑了两个学期掉线率比之前用原生 ping 低很多。注意如果中间还有云负载均衡各层空闲超时阈值要从外到内递进这个坑在第 5 章展开。4.2 Redis 缓存一致性成绩更新后怎么让旧数据失效上一章的成绩查询接口用了缓存但“更新后清理缓存”的顺序有讲究。最直观的做法是业务先更新数据库再删除缓存。问题在于如果先更新库、删除缓存前服务重启缓存里还是旧数据如果把顺序反过来先删缓存再更新库更新库期间又有新请求把旧数据写进缓存窗口期更长。我采用的方案是 Cache Aside 延迟双删更新数据库后立即删一次缓存隔 500 毫秒再删一次。第二次删除是为了处理“第一次删除后、数据库更新完成前恰好有请求把旧值写回缓存”的竞态。Transactional public void updateScore(ScoreUpdateCmd cmd) { scoreMapper.updateScore(cmd); String cacheKey score: cmd.getUserId() : cmd.getSemester(); redisTemplate.delete(cacheKey); // 延迟第二次删除兜底并发读出的脏缓存单独用调度线程不影响心跳扫描 cacheScheduler.schedule(() - redisTemplate.delete(cacheKey), 500, TimeUnit.MILLISECONDS); }逻辑说明Transactional 保证数据库更新成功才进入缓存删除流程延迟删除用独立的单线程调度器避免和心跳扫描共用线程池防止一个慢任务阻塞另一个。这个 500 毫秒不是玄学它取决于数据库更新 SQL 正常情况下需要多久我一般先压测出 95 分位的更新耗时再把延迟设成该耗时的一倍。这套方案做不到强一致但在智慧校园场景里成绩最多延迟数百毫秒可见老师和学生都能接受真需要强一致就回源数据库查询而不是继续优化缓存。缓存穿透也要堵一下如果恶意用户用随机学号刷查询接口每次都会越过缓存打到数据库。我会在学生数据导入时把全量学号写进一个布隆过滤器查询前先判断学号是否存在布隆过滤器的实现原理是多个哈希函数映射位图有误判但不会漏判误判率控制在 1% 以内挡住大多数无效 key 已经够用。4.3 云端部署形态容器化单体比盲目微服务更可靠很多人在“云端管理系统”上会误以为必须拆微服务。我从一线经济性角度劝一句智慧校园这种体量日活几千、峰值并发几百用容器化单体最合适。我一般把应用打成 Docker 镜像用 Nginx 做转发层MySQL 用云数据库实例Redis 用托管实例静态文件学生照片、公告附件放对象存储。“云端”体现在三个地方应用可水平扩容、数据库由托管服务保证可靠、存储不占应用磁盘。部署的容器编排文件很简洁services: app: image: school-app:latest ports: [8080:8080] environment: DB_URL: jdbc:mysql://mysql-instance:3306/base_db REDIS_HOST: redis-instance nginx: image: nginx:alpine ports: [80:80, 443:443] volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf先容器化单体而不是直接上 Kubernetes 全家桶后者对小团队的学习成本和运维压力远大于收益。单体架构下只有一个地方需要提前预留扩展点把无状态应用副本扩到 2 个以上后定时任务和 WebSocket 连接要避免重复和串台这两个问题就是下一章的主要内容。5. 避坑指南智慧校园云端管理系统从开发到上线的 5 条血泪经验以下每一条我都按“现象 → 原因 → 解决”的方式写这些坑我都在真实环境里踩过能帮你省下不少排查时间。5.1 JWT 密钥硬编码测试环境没问题上线三天被薅现象系统上线后某天日志里出现大量使用同一 token 的请求有人遍历了所有学生信息的查询接口。原因开发时把 secret 写在代码里提交到了仓库又忘记改默认值拿到源码的人可以直接伪造管理员 token。解决secret 立即改为环境变量注入并跑一个定时任务清理已签发的旧 token强制所有人重新登录。密钥类配置一辈子不能进代码仓库本地开发可以用 .env 文件但必须加入 .gitignore。这个坑最可怕的地方在于测试阶段完全察觉不到因为开发环境没人会伪造 token。5.2 WebSocket 被云网关切断控制台显示断开重连循环现象浏览器 WebSocket 连接经常在早晨或者学生集中回宿舍时断开控制台不断重连服务端日志显示“Connection closed unexpectedly”。原因Nginx 转发层默认的 read timeout 是 60 秒心跳间隔 30 秒理论上不会触发。但如果中间还有一层云负载均衡某一层的空闲超时更短就会从中间切断连接另外浏览器切后台后 JS 定时器被冻结心跳也就停了。解决把 Nginx 的 proxy_read_timeout 调到 120 秒并开启 WebSocket 协议升级前端监听 visibilitychange页面回到前台时立即补发一次心跳。各层超时阈值要从外到内递进外层 120 秒、内层 90 秒保证外层先断开而不是内层先断。5.3 批量导入成绩超时同步提交一千条数据直接翻车现象教务老师用模板导入一千条成绩页面转圈三分钟后报 504。原因导入接口在事务里逐条 INSERT一条一条提交一千条产生了上千次数据库交互加上每条都做了存在性查询耗时被放大到分钟级。解决改为分批批量插入每批 200 条用 MyBatis 的 batch 模式存在性校验先全部查出来放内存字典再逐条比对事务改为每个批次一个事务整体导入完成后返回统计结果。这样一千条导入从三分钟降到十秒左右教务老师的体验完全不同。5.4 定时任务在集群里重复执行Scheduled 的隐藏问题现象部署了两个应用实例后每天凌晨的统计汇总任务跑了两遍数据汇总翻倍。原因Scheduled 在每个实例都会执行单机时看不出来扩了副本就出问题。解决用 Redis 分布式锁给任务加锁以任务名为 keysetnx 成功才执行执行完成后释放。锁要带过期时间防止任务异常崩溃后锁永远不释放。String lockKey job:score-summary-lock; // setIfAbsent 带过期时间10分钟内没执行完锁自动释放避免死锁 Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.MINUTES); if (Boolean.TRUE.equals(locked)) { try { doSummary(); } finally { redisTemplate.delete(lockKey); } }5.5 权限缓存不失效改了角色用户还是旧权限现象管理员给某位教师调整了角色教师刷新页面后仍旧看不到新菜单。原因登录时把角色列表放进了 JWT而 JWT 没过期之前不会重新签发权限校验直接信任 JWT 里的角色没有回源查角色表。解决JWT 里只存 userId 和一个 roleVersion权限过滤时先比较缓存中的 roleVersion 与 token 中的是否一致不一致就拒绝并让前端请求重新登录。这比每次请求都查角色表更灵活也比把角色写死进 JWT 更安全。6. 压测三步与验收清单用数据判断系统能否扛住开学季高峰上线前最重要的一步是用压测数据代替“我觉得没问题”。我习惯用 JMeter 或 wrk 这类常见工具做接口压测重点不是看平均响应时间而是看错误率和 P99。6.1 压测三步线程数、Ramp-Up、断言第一步是确定场景。开学季最典型的场景是选课开始瞬间几千人同时访问选课接口和成绩查询接口。我会把这两个接口按 7:3 的比例混合压测。第二步是设置参数线程数从 100 起步Ramp-Up 时间设为 30 秒让请求慢慢爬坡而不是瞬间全部打进来这样更接近真实用户行为。第三步是加断言响应码必须 200响应体包含指定字段失败率超过 1% 就视为不达标。压测跑三遍取中间值第一遍通常用来“热身”。同时用 Spring Boot Actuator 暴露 /actuator/health配合云监控的探针做存活检查压测时观察 CPU 和连接池指标别只盯着响应时间。6.2 验收清单开学季场景里的功能边界测试压测只是性能维度功能维度我还会按以下清单走一遍每一条都是真实事故换来的切换账号登录后旧 token 是否能立即失效辅导员换到另一个学院后原来学院的数据是否不可见批量导入一万条成绩是否在可接受时间内完成WebSocket 断网重连后能否自动恢复未读通知两台应用实例同时启动定时任务只执行一次。清单里前两条对应权限边界后三条对应分布式环境的一致性问题任何一条不过关都不能放上生产。6.3 先压单体再谈微服务最后说一个我的教训。第一版系统我直接上了微服务架构拆了网关、认证、用户、教务四个服务结果开发期本地调试要开六个进程部署要维护三套配置。后来先压测单体版本发现数据库连接池为 50 时 TPS 能做到 800 以上已经是开学季峰值的五倍于是把微服务拆掉了保留了一个可以水平扩容的应用服务架构成本降到最低。现在如果再有同事提议先上微服务我会让他先压一压单体再说。希望这些用真金白银换来的经验能帮到你哪怕只帮你少踩一个坑这篇就值了。本文还有配套的精品资源点击获取