ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从数据库到权限:Spring Boot实现大学生综合素质测评系统核心设计

从数据库到权限:Spring Boot实现大学生综合素质测评系统核心设计 简介这是一份基于Java的大学生综合素质测评系统设计与实现文档采用docx格式共1个文件压缩包仅493KB轻量易用。资源面向需要完成软件工程课程设计、毕业设计或Java Web入门实践的学生与开发者也适合高校教务人员了解测评系统的信息化方案。文档以大学生综合素质评价为切入点针对传统评价不够系统化、数据化的问题构建了基于B/S结构、JSP动态网页、Tomcat服务器、MySQL数据库以及MyEclipse开发环境的完整技术方案并分章阐述了问题的提出、新系统目标与意义、系统需求分析、系统功能设计等内容。在需求分析部分依次给出系统流程图、数据流程图、实体联系图和功能模块图帮助读者理清管理流程与数据关系在功能部分则说明软件模块结构设计能够直观展示各功能模块的划分与关联。全文从构思、分析到设计环环相扣可作为毕业设计说明书或系统开发报告的写作范本。该文档已有71人学习对准备搭建同类测评系统的开发者具有现实参考价值。1. 为什么这类题目十年了还是Java课程设计的热门选择大学生综合素质测评系统并不是一个新概念但它每年都会出现在大量Java课程设计和毕业设计选题里原因在于它恰好覆盖了JavaWeb开发最核心的能力闭环面向对象建模、关系型数据库设计、业务分层架构、事务与异常处理、以及多角色权限控制。一个测评系统通常要处理学生自评、辅导员审核、指标打分、权重汇总、结果公示与查询这些场景在真实企业系统中几乎原样存在所以它被当作「既能看出基础又能看出工程意识」的题目。我见过不少同学把这类系统做成了CRUD页面堆砌管理员把分数录进去学生登录看一眼排名然后答辩结束。这其实浪费了这个题目的价值。本文按我自己做类似系统的思路来拆解先讲综合素质测评的指标模型怎么落地成数据库表再给出一套用Spring Boot MyBatis Plus实现评分与汇总的代码骨架然后讲多角色状态流转和Excel导入防坑最后落在测评公平性相关的进阶处理上。全程可以直接复制到自己的项目里改。2. 综合素质测评系统的指标模型与数据表设计2.1 先搞清楚测评单元个人分和班级排名是怎么算出来的综合素质测评的常规做法是分模块打分再加权汇总。大多数高校会把指标分成思想品德、学业成绩、文体活动、社会实践与创新创业这几个一级模块每个模块下再挂二级指标。比如思想品德包含了政治素养、遵纪守法、集体活动参与度文体活动包含了运动会、文艺演出、社团活动。这些二级指标由辅导员、班委或者学生本人打分最终加权得到一个总分。这里的核心设计决策是不要为每个模块单独建一张分数表也不要为每个指标建一个字段。前者会让查询时产生大量关联后者会让表结构死掉——下学期指标体系一调整你就得改表。正确做法是把「指标」本身做成一张可配置的表把「学生在某指标上的得分」单独存成一条记录这就是典型的EAVEntity-Attribute-Value模型变体。虽然它在超大数据量下不是最优解但在一个学院几千人的规模下灵活性远比极致性能重要。系统里最重要的三个核心实体是学生、测评批次、指标实例。同一个学生在大一和大二的测评使用的是两套可能不同的指标体系所以指标不能直接关联学生必须先关联「批次」。批次表保存了学年、学期和当前状态指标表通过批次ID关联分数表再关联学生ID和指标ID。这种三层关系是整个系统的地基后面写的所有代码都围绕它转。2.2 E-R关系与建表SQL把指标体系做成可配置的表结构这里给出一个可以直接用的核心表设计。我故意省略了账号密码、班级等常规字段聚焦在测评域内。-- 测评批次表 CREATE TABLE evaluate_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_name VARCHAR(100) NOT NULL COMMENT 批次名称如2024-2025学年第一学期, start_date DATE NOT NULL, end_date DATE NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-草稿 1-测评中 2-已归档, total_weight DECIMAL(5,2) DEFAULT 100.00 COMMENT 总分满分值通常为100, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 指标项表挂在批次下面 CREATE TABLE evaluate_indicator ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id BIGINT NOT NULL COMMENT 所属批次, parent_id BIGINT DEFAULT NULL COMMENT 父指标ID一级指标该字段为NULL, indicator_name VARCHAR(50) NOT NULL COMMENT 指标名称, weight DECIMAL(5,2) NOT NULL COMMENT 指标权重如20.00表示20%, score_type TINYINT NOT NULL COMMENT 1-直接打分 2-选项制(优秀/良好/合格) 3-加减分制, max_score DECIMAL(6,2) DEFAULT 100.00, sort_order INT DEFAULT 0, UNIQUE KEY uk_batch_indicator (batch_id, parent_id, indicator_name) ); -- 学生得分明细表 CREATE TABLE evaluate_score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id BIGINT NOT NULL, student_id BIGINT NOT NULL COMMENT 学生ID, indicator_id BIGINT NOT NULL COMMENT 指标ID, score DECIMAL(6,2) NOT NULL COMMENT 得分, scorer_type TINYINT NOT NULL COMMENT 评分人类型1-学生自评 2-辅导员 3-班委, scorer_id BIGINT NOT NULL, remark VARCHAR(255), audit_status TINYINT DEFAULT 0 COMMENT 审核状态0-待审核 1-通过 2-驳回, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_score (batch_id, student_id, indicator_id, scorer_type) ); -- 测评汇总结果表 CREATE TABLE evaluate_summary ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id BIGINT NOT NULL, student_id BIGINT NOT NULL, final_score DECIMAL(6,2) NOT NULL, rank_in_class INT DEFAULT NULL COMMENT 班级排名, grade_level VARCHAR(10) COMMENT 优秀/良好/合格/不合格, detail_json TEXT COMMENT 各模块得分快照冗余存储, UNIQUE KEY uk_summary (batch_id, student_id) );关键设计点在三处。evaluate_indicator和evaluate_score是一对多关系分数表不存冗余的指标名称只存indicator_id这样指标改名不会影响历史得分。scorer_type字段是整个系统的灵活点同一个指标可能同时需要学生自评和辅导员评分两种分都要保留最终汇总时再按角色权重合并。evaluate_summary里的detail_json看起来很冗余但它的价值在于当辅导员在「归档」后修改了某个指标的定义历史汇总结果不会受影响。从查询性能角度看不需要为evaluate_score建立额外的复合索引。uk_score这个唯一键已经覆盖了「按批次查某学生所有得分」和「按批次查某指标所有学生得分」两条主要查询路径。唯一键既是索引又能防重复提交一张评分表里同一批次、同一学生、同一指标、同一评分人类型只能有一条记录天然避免了多次录入的矛盾数据。2.3 为什么一定需要批次这个概念而不是直接用学期字段很多初学者会把batch_id换成academic_year和semester两个字符串字段这是一个会在答辩时被追问的设计错误。不同年级的测评指标和权重很可能不一样大一新生可能没有科研加分项大四学生可能多一个毕业实习模块。如果用学期字符串过滤当某个班级因为休学复学导致学制异常时数据归属就会混乱。批次是一个独立的生命周期对象它有草稿、测评中、已归档三种状态。草稿阶段允许增删指标、批量导入学生测评中阶段指标锁定但允许打分和修改分数归档后所有数据不可修改只能查询导出。这套状态机把权限控制变得特别清晰——不只是用户角色的权限还包括时间维度的数据操作权限。后文在讲多角色流程时这个批次状态会作为关键的判断开关。3. 用Spring Boot实现测评核心业务配置、打分、汇总3.1 项目结构与配置一次说清分层和依赖选型技术栈用最稳妥的组合Spring Boot 2.7.x MyBatis Plus 3.5.x MySQL 8.0 Hutool工具库。不用Spring Cloud不引入分布式事务这个规模下单体应用加本地事务完全够用。项目结构上我习惯按业务域分包而不是按技术分层分包com.example.evaluation ├── config # 全局配置、WebMvc拦截器注册 ├── controller # 只做参数接收和返回封装 ├── service # 业务接口与实现 ├── mapper # MyBatis Plus的Mapper接口 ├── entity # 数据库实体 ├── dto # 入参出参对象 ├── common # 统一返回体、业务异常、常量 └── util # Excel导入导出、权重计算工具application.yml里的三个参数常规但重要。spring.datasource连接池用HikariCPmaximum-pool-size设为10足够支撑几千学生的并发打分spring.servlet.multipart.max-file-size要单独加大因为后面会用Excel批量导入指标和学生名单默认1MB不够用MyBatis Plus的logic-delete全局配置打开后所有删除操作自动变成更新逻辑删除字段但这里不建议对evaluate_score开启逻辑删除分数记录需要物理删除能力否则指标重录时会累积大量废弃数据。3.2 评分提交接口事务、防重复、权重校验一起做打分操作是整个系统并发压力最大的接口班级测评的时候一个辅导员可能要连续提交几百条记录。一个完整的打分动作包含三步更新或插入evaluate_score、计算该学生在该批次的所有指标得分、刷新evaluate_summary汇总表。这三步必须在同一个事务里完成。先看Service层的核心实现这里省略了Controller的包装逻辑只写关键业务代码。Service RequiredArgsConstructor public class EvaluateScoreServiceImpl implements EvaluateScoreService { private final EvaluateScoreMapper scoreMapper; private final EvaluateBatchMapper batchMapper; private final EvaluateIndicatorMapper indicatorMapper; private final EvaluateSummaryMapper summaryMapper; Transactional(rollbackFor Exception.class) public void submitScore(ScoreSubmitDTO dto) { // 1. 校验批次当前是否处于可打分状态 EvaluateBatch batch batchMapper.selectById(dto.getBatchId()); if (batch null || batch.getStatus() ! 1) { throw new BizException(批次不存在或当前不在测评中无法打分); } // 2. 校验指标是否存在且属于该批次防止跨批次伪造数据 EvaluateIndicator indicator indicatorMapper.selectById(dto.getIndicatorId()); if (indicator null || !indicator.getBatchId().equals(dto.getBatchId())) { throw new BizException(指标不存在或不属于该批次); } // 分值边界校验score_type为1时直接打分不能超过max_score if (indicator.getScoreType() 1 dto.getScore() indicator.getMaxScore()) { throw new BizException(得分超过指标上限); } // 3. 通过唯一键执行insertOrUpdate避免并发下重复插入 EvaluateScore score new EvaluateScore(); score.setBatchId(dto.getBatchId()); score.setStudentId(dto.getStudentId()); score.setIndicatorId(dto.getIndicatorId()); score.setScore(dto.getScore()); score.setScorerType(dto.getScorerType()); score.setScorerId(dto.getScorerId()); score.setRemark(dto.getRemark()); // 使用自定义SQL实现 ON DUPLICATE KEY UPDATE scoreMapper.insertOrUpdate(score); // 4. 重新计算该学生总分并更新汇总表 recalculateStudent(batch.getId(), dto.getStudentId()); } private void recalculateStudent(Long batchId, Long studentId) { // 关键: 一次性查出该学生所有指标得分, 内存中做权重加权 ListScoreWeightVO list scoreMapper.selectScoreWithWeight(batchId, studentId); BigDecimal total BigDecimal.ZERO; for (ScoreWeightVO vo : list) { // 按评分人角色取不同占比: 自评20% 辅导员80% BigDecimal roleFactor 1.equals(vo.getScorerType()) ? new BigDecimal(0.2) : new BigDecimal(0.8); total total.add(vo.getScore().multiply(vo.getWeight()) .multiply(roleFactor)); } EvaluateSummary summary new EvaluateSummary(); summary.setBatchId(batchId); summary.setStudentId(studentId); summary.setFinalScore(total); summary.setGradeLevel(gradeLevel(total)); summaryMapper.insertOrUpdate(summary); } }insertOrUpdate不是MyBatis Plus内置的saveOrUpdatesaveOrUpdate是先查后改两个并发请求同时进来会撞唯一键。这里建议在Mapper里写一条自定义SQL用INSERT ... ON DUPLICATE KEY UPDATE score VALUES(score)把并发问题从根上解决。recalculateStudent里的角色权重写法有一个需要留意的点每个指标的scorer_type不能混用。如果一个指标同时要求自评和辅导员评分这条SQL会返回两条记录一条scorer_type为1、另一条为2它们先分别乘角色系数再和指标权重相乘累加。这里没有和指标当前的score_type做互斥判断设计阶段就要规定好选项制指标和直接打分指标只能由辅导员提交加减分制指标可以由班委发起、辅导员确认。职责混在一起会让权重计算出现语义错误。3.3 批量导入设计先校验后插入字段映射要可配置综合素质测评系统中学生名单和指标体系的导入是最容易让新手翻车的环节。你几乎不可能拿到一份格式完全标准的Excel常见的情况是「学号」一列叫studentNo也可能叫学号或Student ID。指标Excel里经常混着「加分依据」这种自由文本列它不属于数值字段但又是后续审计的依据。导入策略上我的做法是两步走。第一步先把Excel读成ListMapInteger, String列序映射到字段名时用「模板配置」而不是硬编码。比如你可以在后台维护一张导入模板字段映射表数据库里存模板ID、Excel列号、目标字段名。第二步把数据批量组装成实体后先做内存级校验再做批量插入校验失败的行要完整地返回给前端告诉用户是第几行、哪个字段、什么原因。public ImportResult importIndicators(MultipartFile file, Long batchId) { // 使用Hutool的ExcelReader读取忽略空行 ExcelReader reader ExcelUtil.getReader(file.getInputStream()); ListListObject rows reader.read(); if (rows.size() 2) { return ImportResult.fail(Excel中没有数据行); } ListEvaluateIndicator insertList new ArrayList(); ListErrorRow errors new ArrayList(); // 从第2行开始遍历跳过表头 for (int i 1; i rows.size(); i) { ListObject row rows.get(i); try { EvaluateIndicator indicator new EvaluateIndicator(); indicator.setBatchId(batchId); indicator.setIndicatorName(String.valueOf(row.get(0))); indicator.setWeight(new BigDecimal(String.valueOf(row.get(1)))); indicator.setScoreType(Integer.parseInt(String.valueOf(row.get(2)))); indicator.setMaxScore(String.valueOf(row.get(3)).isEmpty() ? new BigDecimal(100) : new BigDecimal(String.valueOf(row.get(3)))); if (indicator.getWeight().compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(权重必须大于0); } insertList.add(indicator); } catch (Exception ex) { errors.add(new ErrorRow(i 1, ex.getMessage())); } } // 校验该批次下已存在指标的权重总和不超过100 BigDecimal existingWeight indicatorMapper.sumWeightByBatchId(batchId); BigDecimal newWeight insertList.stream() .map(EvaluateIndicator::getWeight) .reduce(BigDecimal.ZERO, BigDecimal::add); if (existingWeight.add(newWeight).compareTo(new BigDecimal(100)) 0) { return ImportResult.fail(导入后指标总权重超过100%当前已存在权重: existingWeight); } // 批量插入使用分批方式避免SQL参数过多 insertList.forEach(indicator - indicatorMapper.insert(indicator)); return ImportResult.success(insertList.size(), errors); }这个导入逻辑最容易被忽略的是权重总和校验。很多系统把权重校验放在评分阶段才做结果是学生已经提交了一部分分数辅导员才发现权重加起来只有90分又得等管理员重新调汇总逻辑。指标导入时一次性校验权重总和把问题拦截在最前端。批量插入的性能在Excel小于500行时差异不大但如果指标细化到三级、二级指标一个批次可能塞进2000条记录MyBatis Plus自带的insertBatchSomeColumn是安全的它能自动按数据库参数上限分批。4. 多角色权限与测评流程的状态流转控制4.1 基于RBAC的权限模型学生、班委、辅导员、管理员四种角色综合素质测评系统的权限优先级从低到高是学生、班委、辅导员、系统管理员。学生只能维护自己的资料、提交自评分、查看自己的汇总结果班委能录入文体活动加分和扣分记录辅导员拥有全部业务操作权限包括给所有指标打分、发起批次归档、导出班级汇总表系统管理员只负责维护账号、配置指标体系和查看异常日志。我用Spring Security实现时不搞细粒度的数据权限框架直接在Service方法入口用自定义注解或者手动判断角色。这个规模下引入PreAuthorize(hasRole(ADMIN)) 方法级安全最稳妥。但需要留意的是光在接口上做权限校验还不够数据级的横向越权才是重点。一个学生补齐了studentId参数请求别人班级的评分数据时接口应当能识别出来。Component public class DataScopeInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 从请求头获取当前登录用户 LoginUser user UserContext.get(); if (user null) { throw new BizException(登录已过期); } String requestUri request.getRequestURI(); // 管理员跳过数据范围校验其他角色只能操作自己班级/自己学生的数据 if (user.getRole().equals(ADMIN)) { return true; } String studentId request.getParameter(studentId); if (StringUtils.hasText(studentId) !user.canAccessStudent(Long.parseLong(studentId))) { throw new BizException(无权访问该学生的数据); } String batchId request.getParameter(batchId); if (StringUtils.hasText(batchId) !user.canAccessBatch(Long.parseLong(batchId))) { throw new BizException(无权访问该批次); } return true; } }这里校验的数据流向是学生角色只能操作studentId等于自己ID的记录班委除了自己的数据外还能操作自己所在班级的文体活动指标辅导员能操作自己名下班级的所有记录。判断逻辑全部封装在canAccessStudent和canAccessBatch中后面接的是CONDITION查询条件拼接这里不多展开。从实际踩坑经验看最容易出的安全漏洞不在角色判断而在请求参数缺失的默认行为。如果前端漏传了studentId上面代码走不进第一个分支继续往下查询时会查到该批次所有学生的记录在极少数班级规模的测评中不会出问题但你如果批量导出了几个班的数据就成了越权事件。所以preHandle里必须把两个参数都做强校验缺一不可。4.2 批次状态机与操作校验草稿、测评中、归档三态跳转批次状态机是测评流程能否顺畅运转的保险丝。在整个流程里状态跳转必须由明确的操作触发而不是靠时间字段自动切换。日期到了不代表测评完成辅导员可能还没核完所有学生的自评分同样归档也不是简单改一个状态字段归档前必须预先校验所有学生的分数完整度。定义三个状态常量public interface BatchStatus { int DRAFT 0; // 草稿允许改指标、导学生 int EVALUATING 1; // 测评中指标锁定允许打分 int ARCHIVED 2; // 已归档所有写操作拒绝 }状态流转只允许0-1、1-2两种路径。从草稿切到测评中时系统做的事情包括校验指标权重总和等于100%、校验参加测评的学生数量大于0、锁定所有指标字段。这里不能只做前端按钮禁用后端在批次Service里要做一个状态机门卫Transactional(rollbackFor Exception.class) public void transitionBatchStatus(Long batchId, int targetStatus) { EvaluateBatch batch batchMapper.selectById(batchId); if (batch null) { throw new BizException(批次不存在); } int current batch.getStatus(); // 只允许相邻状态流转 if (!(current 0 targetStatus 1) !(current 1 targetStatus 2)) { throw new BizException(非法状态流转: current - targetStatus); } if (targetStatus 1) { // 草稿转测评中的前置校验 BigDecimal totalWeight indicatorMapper.sumWeightByBatchId(batchId); if (totalWeight.compareTo(new BigDecimal(100)) ! 0) { throw new BizException(指标总权重必须等于100%当前为: totalWeight); } } if (targetStatus 2) { // 归档前校验每个学生的评分完整度是否达到100% checkScoreComplete(batchId); } EvaluateBatch update new EvaluateBatch(); update.setId(batchId); update.setStatus(targetStatus); batchMapper.updateById(update); }checkScoreComplete实现细节值得多说一句。查出一个批次下所有学生再按学生维度聚合出该学生在该批次中的所有指标权重之和如果某个学生的已打分数对应的指标权重不足100%说明还有漏评项就应该返回引导学生列表而不是直接归档成功。这块我一般用一条SQL加内存聚合就能完成不需要额外写循环查库。4.3 分数审核与申诉流程用两张表把状态从单点改成流转学生自评和班委加分通常需要辅导员审核后才生效。如果把审核动作直接更新在evaluate_score上就丢失了「谁在什么时间改了什么」的审计轨迹。常见做法是用一张额外的流程表记录状态流转分数表只做冗余存储。evaluate_score evaluate_score_audit ---------------- ---------------- | score 原分数 | ----- | current_score | | audit_status | ----- | next_score | ---------------- | operator_id | | action: SUBMIT/AUDIT/REJECT/RESUBMIT | remark | ----------------每次辅导员审核通过或驳回往流程表插一条记录同时把evaluate_score的字段更新成最新值。出现争议时翻流程表就能还原出来这条分数经过了几次驳回和修改。这就是把状态从「点在某个字段上」提升到「连成一条事件流」。需要特别提醒的是一旦学生的自评分被辅导员驳回学生端应该能立刻看到一个「待重新提交」的状态而不是继续显示旧的分数。实现方式也很简单audit_status加上时间戳后前端读取分数时加一条过滤条件只显示流程表里最新一条记录对应的有效分数。这是测评系统中最容易被写成「改来改去最后版本对不上」的地方。5. 汇总计算与Excel导出排名、等级和报表一次讲透5.1 班级排名与等级划分一个容易算错的名次问题学生的final_score算出来之后排名逻辑看起来很简单按分数降序不就是了吗。但真实需求往往更细——辅导员要的是班级内排名而不是全院排名而且班级的判定标准是「同一批次同一年级同一班级」不是简单按class_id分组。如果学生存在转专业的情况他中途换了班级历史批次的班级归属就不能依赖当前class_id而要用批次报名时的班级快照。我一般是在汇总表里冗余一个class_id字段在批次初始化学生名单时把当时的学生所属班级复制过来。这样做的核心原因是转专业学生的历史测评数据不会被当前班级信息污染。排名本身用RANK()和DENSE_RANK()都要按需求选。综合素质测评通常用并联排名同分同名次所以用RANK()而不是ROW_NUMBER()。UPDATE evaluate_summary es JOIN ( SELECT student_id, batch_id, RANK() OVER (PARTITION BY batch_id, class_id ORDER BY final_score DESC) AS rk FROM evaluate_summary WHERE batch_id #{batchId} ) t ON es.student_id t.student_id AND es.batch_id t.batch_id SET es.rank_in_class t.rk;等级划分优秀/良好/合格/不合格不能按绝对分数比如90分以上优秀因为不同批次的整体难度和评分松紧可能不同。常规做法是「按比例与分数线双轨制」全班前20%且不低于85分认定为优秀前50%且不低于75分为良好剩余中不低于60分为合格低于60分或排名最后5%为不合格。注意这里「最后5%且分数不低」的学生仍然可能被判定为不合格这是为了保证测评结果有区分度。双轨制在代码实现上要注意顺序先算排名比例再按分数线修正两次取交集才确定等级。不要先在SQL里把等级符写死后续指标权重调整时会很痛苦。5.2 Excel报表导出大数据量下用SXSSF而不是XSSF导出成绩汇总表经常被做成全量查询再逐行写Excel。几千学生直接全量加载到内存虽然不会OOM但会让JVM GC变慢导出接口响应变长。Apache POI的SXSSFWorkbook是流式写入方案核心原理是维护一个滑动窗口窗口之外的行自动刷写到磁盘内存占用从O(N)降到O(窗口大小)。public void exportSummary(Long batchId, HttpServletResponse response) { // 滚动查询每次取500个学生 ListEvaluateSummary summaries summaryMapper.selectByBatchId(batchId); try (SXSSFWorkbook workbook new SXSSFWorkbook(100)) { Sheet sheet workbook.createSheet(测评汇总); // 写表头 String[] headers {学号, 姓名, 班级, 总分, 排名, 等级}; Row headerRow sheet.createRow(0); for (int i 0; i headers.length; i) { headerRow.createCell(i).setCellValue(headers[i]); } // 写数据 for (int i 0; i summaries.size(); i) { Row row sheet.createRow(i 1); row.createCell(0).setCellValue(summaries.get(i).getStudentNo()); row.createCell(1).setCellValue(summaries.get(i).getStudentName()); row.createCell(2).setCellValue(summaries.get(i).getClassName()); row.createCell(3).setCellValue(summaries.get(i).getFinalScore().doubleValue()); row.createCell(4).setCellValue(summaries.get(i).getRankInClass()); row.createCell(5).setCellValue(summaries.get(i).getGradeLevel()); } // 导出响应设置 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenamesummary_ batchId .xlsx); workbook.write(response.getOutputStream()); } catch (IOException e) { throw new BizException(导出失败: e.getMessage()); } }SXSSFWorkbook(100)的构造参数指滑动窗口大小也就是内存中最多保留100行其余写到临时文件。对测评系统千级数据量来说100是个够用的值。表格样式上不要用POI做边框美化实测中Excel的「套用表格格式」功能比POI反复设置CellStyle要快得多。5.3 说一说导出性能优化导出前经常遇到页码过滤、按姓名搜索、按等级筛选这些条件。如果每次筛选都先查出符合条件的全量数据再循环写Excel性能瓶颈最终出现在全量查询上。应对方案是让SQL查询也走滚动游标MyBatis的Cursor可以配合流式读取Mapper public interface EvaluateSummaryMapper { Select(SELECT * FROM evaluate_summary WHERE batch_id #{batchId} ORDER BY final_score DESC) CursorEvaluateSummary streamByBatchId(Long batchId); }调用方用try-with-resources包裹Cursor再配合SXSSFWorkbook就能以近似流式的方式导出任意批次的数据全程内存占用稳定。这个方案真正的单点故障只会出现在网络传输中断时所以导出接口最好配合异步任务和文件下载链接而不是直接响应流。本文不展开异步任务的实现只提示导出给辅导员用是管理员自己下载给学生的自助下载应该走预生成的静态文件。6. 绕不开的细节公平性保护、并发兜底和一套自检SQL6.1 防作弊机制修改留痕和分数突变拦截综合素质测评系统最敏感的是分数可信度。一个技术上实现得很完整的系统如果学生质疑分数被改过而系统拿不出证据链整个设计就失败了。我在系统里加了两个轻量级的保护机制成本不高但能挡住绝大多数真实质疑。第一层是「修改留痕」。凡是evaluate_score发生UPDATE都在score_change_log表里追加一条记录保存旧值、新值、操作人、操作时间和IP。实现用MyBatis Plus的MetaObjectHandler只能拦截插入时的公共字段更新留痕需要显式在Service层中记录。我的惯用做法是写一个RecordScoreChange注解切面打上注解的方法在返回前自动比对入参和库中旧值有差异才落日志。这样比在每个更新方法里手写日志代码干净得多。第二层是「分数突变拦截」。如果一个学生在两个批次之间的测评总分从70分跳到98分管理员应当能看到系统自动标记的可疑记录。实现很简单归档批次时跑一遍SELECT s.student_id, s.final_score AS current_score, p.final_score AS previous_score, (s.final_score - p.final_score) AS diff FROM evaluate_summary s LEFT JOIN evaluate_summary p ON p.student_id s.student_id AND p.batch_id (#{batchId} - 1) WHERE s.batch_id #{batchId} AND (s.final_score - p.final_score) 20这里的batchId - 1是逻辑上相邻批次的意思实际实现时应该用p.batch_id取小于当前批次且最大者而不是简单的算术减一。查出突变名单后后台生成「异常分数预警列表」辅导员在归档之前逐项确认。这是一个面向真实业务痛点的功能写进论文和答辩词都是加分项。6.2 并发兜底评分接口的幂等和服务降级测评高峰期最容易出现的问题是学生提交自评和班委提交加分并发集中在截止前30分钟入库。除了唯一键约束外还需要在Controller入口加一个简单的并发控制。不用引入Redisson分布式锁单体应用下用ReentrantLock做本地锁就够了。private final MapLong, ReentrantLock lockMap new ConcurrentHashMap(); public void submitScoreWithLock(ScoreSubmitDTO dto) { // 对一个学生同一个批次的打分加锁避免并发重复计算汇总 ReentrantLock lock lockMap.computeIfAbsent(dto.getStudentId(), k - new ReentrantLock()); lock.lock(); try { submitScore(dto); } finally { lock.unlock(); } }这段代码的核心意图是杜绝同一个学生同一批次的两个打分请求同时进入recalculateStudent否则后写入的汇总结果会覆盖前一个。ConcurrentHashMap的computeIfAbsent是原子操作并发安全。锁粒度已经细化到学生ID不会因为一个学生慢查询拖垮全系统。还要注意一个容易被忽略的场景辅导员批量导入分数时如果Excel里有重复行或者重复学生ID程序必须完整报错而不是静默跳过。前面已经做了唯一键兜底但唯一键冲突的异常在批量插入时是回滚单条还是回滚整批取决于你是否开启了allowMultiQueries。默认ON DUPLICATE KEY UPDATE行为就是更新原有记录所以即便Excel重复了两行也不会插入失败但实际语义可能不对——你应该在解析Excel阶段就查重而不是依赖数据库兜底。6.3 一套每天跑一遍的数据自检SQL系统上线跑一个学期后各种脏数据可能悄无声息地积累。我养成的习惯是写几个只读SQL作为「体检脚本」配合定时任务每周执行一次异常数据通过邮件或站内信推送给管理员。-- 体检1: 指标总权重与100不符的批次 SELECT batch_id, SUM(weight) AS total_weight FROM evaluate_indicator GROUP BY batch_id HAVING SUM(weight) ! 100; -- 体检2: 存在未审核分数但批次已归档的数据 SELECT batch_id, COUNT(*) AS unapproved_cnt FROM evaluate_score WHERE audit_status 0 GROUP BY batch_id; -- 体检3: 汇总表和得分明细不一致的学生 SELECT s.student_id, s.batch_id FROM evaluate_summary s LEFT JOIN evaluate_score sc ON sc.batch_id s.batch_id AND sc.student_id s.student_id WHERE sc.id IS NULL;体检2这条SQL特别建议放在批次流转到「归档」的前一步执行。如果未审核分数仍然存在归档必须被阻止。把这条规则写进状态机的checkScoreComplete方法里系统就在流程层杜绝了「辅导员漏审一条记录但批次已经封账」的事故。这套自检SQL不需要做什么花哨的可视化面板直接跑批然后打印结果列表即可。个别系统的管理员甚至直接把它配在cron里输出到日志文件出了问题再查。对课程设计或者小规模部署的测评系统来说这已经足够可靠。本文还有配套的精品资源点击获取
返回列表