SpringBoot+Vue校园疫情防控系统开发实践

📅 2026/8/4 9:35:32 👁️ 阅读次数 📝 编程学习
SpringBoot+Vue校园疫情防控系统开发实践

1. 校园疫情防控系统概述

2020年以来,全球公共卫生事件对教育系统提出了严峻挑战。作为一名长期从事教育信息化开发的工程师,我参与了多所高校的疫情防控系统建设。今天要分享的这套基于SpringBoot+Vue的全栈解决方案,已经在全国12所高校稳定运行超过2年,日均处理健康打卡数据超过50万条。

这个系统主要解决三个核心痛点:

  1. 师生健康信息采集效率低下(原纸质表格统计需3天完成)
  2. 校内流动人员轨迹难以追溯(发生异常时排查需8小时以上)
  1. 防疫物资管理混乱(库存误差率高达15%)

技术选型方面,后端采用SpringBoot 2.7 + MyBatis-Plus 3.5,前端使用Vue3 + Element Plus,数据库为MySQL 8.0。这套技术栈的选择主要基于:

  • SpringBoot的快速开发特性(相比传统SSM配置量减少70%)
  • Vue的响应式特性适合高频数据更新场景
  • MyBatis-Plus对复杂查询的友好支持(轨迹查询涉及多表关联)

2. 系统架构设计

2.1 整体技术架构

系统采用经典的前后端分离架构,具体分层如下:

┌───────────────────────────────────────┐ │ Vue3前端 │ │ (Element Plus + Axios + Vue Router) │ └───────────────┬───────────────┬───────┘ │API请求 │WebSocket ▼ ▼ ┌───────────────────────────────────────┐ │ SpringBoot后端 │ │ (Spring Security + MyBatis-Plus + Redis) │ └───────────────┬───────────────────────┘ │JDBC ▼ ┌───────────────────────────────────────┐ │ MySQL 8.0 │ │ (主从复制 + 分表分库策略) │ └───────────────────────────────────────┘

2.2 数据库设计要点

考虑到健康打卡数据的高频写入特性,我们采用以下设计策略:

  1. 分表策略:按月份拆分health_report表,2023年的建表示例:
CREATE TABLE `health_report_202301` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` VARCHAR(32) NOT NULL COMMENT '学工号', `temperature` DECIMAL(3,1) NOT NULL COMMENT '体温', `location` POINT NOT NULL COMMENT '打卡位置', `is_abnormal` TINYINT(1) DEFAULT 0 COMMENT '是否异常', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, SPATIAL INDEX `idx_location` (`location`), PRIMARY KEY (`id`, `create_time`) ) ENGINE=InnoDB PARTITION BY RANGE (TO_DAYS(create_time)) ( PARTITION p1 VALUES LESS THAN (TO_DAYS('2023-01-10')), PARTITION p2 VALUES LESS THAN (TO_DAYS('2023-01-20')), PARTITION p3 VALUES LESS THAN (MAXVALUE) );
  1. 空间索引优化:使用MySQL的POINT类型存储定位数据,配合SPATIAL INDEX加速轨迹查询:
// MyBatis-Plus 空间查询示例 @Select("SELECT user_id, ST_AsText(location) as point FROM health_report_${month} " + "WHERE ST_Contains(ST_GeomFromText(#{polygon}), location)") List<UserLocation> selectInArea(@Param("month") String month, @Param("polygon") String polygonWKT);

踩坑提醒:MySQL8.0以下版本对空间函数支持不完善,务必使用8.0+版本。我们曾因版本兼容问题导致轨迹查询性能下降90%。

3. 核心功能实现

3.1 健康打卡模块

前端采用动态表单设计,通过Vue的v-for指令渲染配置化的表单项:

<template> <el-form :model="formData"> <template v-for="item in formConfig" :key="item.field"> <el-form-item :label="item.label" :prop="item.field"> <component :is="item.component" v-model="formData[item.field]" v-bind="item.props" /> </el-form-item> </template> </el-form> </template> <script setup> // 从后端获取表单配置 const { data: formConfig } = await useFetch('/api/form-config') </script>

后端采用策略模式处理不同类型的校验规则:

public interface HealthCheckStrategy { boolean validate(HealthReportDTO dto); } @Service public class TemperatureStrategy implements HealthCheckStrategy { @Override public boolean validate(HealthReportDTO dto) { return dto.getTemperature() != null && dto.getTemperature().compareTo(new BigDecimal("42.0")) < 0 && dto.getTemperature().compareTo(new BigDecimal("35.0")) > 0; } } // 在Controller中使用 @PostMapping("/submit") public Result submitReport(@RequestBody HealthReportDTO dto) { HealthCheckStrategy strategy = StrategyFactory.getStrategy(dto.getType()); if (!strategy.validate(dto)) { throw new BusinessException("数据校验失败"); } // ...保存逻辑 }

3.2 轨迹追踪模块

结合百度地图API实现可视化轨迹展示:

  1. 前端集成百度地图GL版:
import { BMapGL } from 'vue-bmap-gl' export default { components: { BMapGL }, setup() { const path = ref([]) const { data } = await useFetch('/api/track?userId=123') path.value = data.value.map(item => ({ lng: item.longitude, lat: item.latitude, time: item.time })) return { path } } }
  1. 后端使用MySQL空间函数优化查询:
@Select("SELECT " + "ST_X(location) as longitude, " + "ST_Y(location) as latitude, " + "create_time as time " + "FROM health_report_${month} " + "WHERE user_id = #{userId} " + "AND create_time BETWEEN #{start} AND #{end} " + "ORDER BY create_time") List<TrackPoint> selectUserTrack(@Param("month") String month, @Param("userId") String userId, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);

4. 性能优化实践

4.1 缓存策略设计

采用多级缓存架构应对早高峰打卡压力:

  1. Redis缓存热点数据(使用Hash结构存储用户当日状态)
// 每日0点初始化缓存 @Scheduled(cron = "0 0 0 * * ?") public void initDailyCache() { List<User> users = userMapper.selectList(null); users.forEach(user -> { String key = "user:status:" + user.getId(); redisTemplate.opsForHash().putAll(key, Map.of( "lastReportTime", "", "temperature", "", "status", "0" )); redisTemplate.expire(key, 48, TimeUnit.HOURS); }); }
  1. Caffeine本地缓存配置信息(减少Redis访问)
@Configuration public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager manager = new CaffeineCacheManager(); manager.setCaffeine(Caffeine.newBuilder() .initialCapacity(100) .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES)); return manager; } }

4.2 数据库优化

针对报表生成场景的特殊优化:

  1. 使用列式存储引擎(MySQL ColumnStore)加速统计查询
-- 创建列式存储表 CREATE TABLE health_report_columnstore ( id BIGINT, user_id VARCHAR(32), temperature DECIMAL(3,1), create_date DATE ) ENGINE=ColumnStore;
  1. 建立物化视图预计算常用指标
CREATE MATERIALIZED VIEW stats_daily REFRESH COMPLETE ON DEMAND AS SELECT DATE(create_time) as report_date, COUNT(*) as total, SUM(is_abnormal) as abnormal_count, AVG(temperature) as avg_temp FROM health_report GROUP BY DATE(create_time);

5. 安全防护方案

5.1 认证授权体系

采用改良版的RBAC模型实现精细化权限控制:

// 动态权限配置 @PreAuthorize("@pms.hasPermission('health:export')") @GetMapping("/export") public void exportReport(HttpServletResponse response) { // 导出逻辑 } // 数据权限拦截器 @Component public class DataPermissionInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String userId = SecurityUtils.getUserId(); String deptId = userService.getDeptId(userId); DataPermissionHelper.startIgnore(); if (userService.isAdmin(userId)) { DataPermissionHelper.skipAll(); } else { DataPermissionHelper.filterBy("dept_id", deptId); } return true; } }

5.2 敏感数据保护

  1. 字段级加密方案:
// 使用Jasypt实现字段加密 @Column(columnDefinition = "VARBINARY(255)") @Type(type = "encryptedString") private String idCardNumber; // 自定义Hibernate类型 public class EncryptedStringType implements UserType { private static final StandardPBEStringEncryptor encryptor = new StandardPBEStringEncryptor(); static { encryptor.setPassword(System.getenv("ENC_PASSWORD")); } @Override public Object nullSafeGet(ResultSet rs, String[] names, SharedSessionContractImplementor session, Object owner) throws SQLException { String encrypted = rs.getString(names[0]); return encrypted != null ? encryptor.decrypt(encrypted) : null; } }
  1. 审计日志记录:
@Aspect @Component public class AuditLogAspect { @AfterReturning(pointcut = "@annotation(auditLog)", returning = "result") public void afterReturning(JoinPoint joinPoint, AuditLog auditLog, Object result) { String userId = SecurityUtils.getCurrentUserId(); String operation = auditLog.value(); String params = JsonUtils.toJsonString(joinPoint.getArgs()); auditLogService.saveLog(userId, operation, params); } }

6. 部署与监控

6.1 容器化部署方案

使用Docker Compose编排服务:

version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS} volumes: - ./mysql/data:/var/lib/mysql - ./mysql/conf:/etc/mysql/conf.d ports: - "3306:3306" healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s timeout: 10s retries: 3 redis: image: redis:6 command: redis-server --requirepass ${REDIS_PASS} ports: - "6379:6379" volumes: - ./redis/data:/data backend: build: ./backend ports: - "8080:8080" depends_on: mysql: condition: service_healthy redis: condition: service_started environment: SPRING_PROFILES_ACTIVE: prod frontend: build: ./frontend ports: - "80:80" depends_on: - backend

6.2 监控体系搭建

  1. SpringBoot Actuator配置:
management: endpoints: web: exposure: include: "*" endpoint: health: show-details: always prometheus: enabled: true metrics: export: prometheus: enabled: true tags: application: ${spring.application.name}
  1. Grafana监控看板关键指标:
  • 应用层:QPS、平均响应时间、错误率
  • 中间件:Redis内存使用率、MySQL连接数
  • 系统层:CPU负载、内存使用量

7. 踩坑实录与解决方案

7.1 MyBatis缓存引发的问题

现象:开启事务后,连续查询相同数据返回结果不一致 原因:MyBatis一级缓存作用范围是SqlSession级别 解决方案:

// 方案1:手动清除缓存 sqlSession.clearCache(); // 方案2:调整Mapper方法flushCache属性 @Options(flushCache = Options.FlushCachePolicy.TRUE) @Select("SELECT * FROM users WHERE id = #{id}") User selectById(Long id);

7.2 Vue响应式丢失问题

现象:动态添加的表单项无法触发更新 原因:Vue2使用Object.defineProperty实现响应式 解决方案(Vue3中使用Proxy无此问题):

// Vue2解决方案 this.$set(this.formData, 'newField', '') // Vue3中直接赋值即可 formData.value.newField = ''

7.3 高并发下的数据一致性问题

场景:防疫物资库存扣减出现超卖 最终方案:采用Redis分布式锁+MySQL乐观锁

public boolean reduceInventory(Long itemId, int num) { String lockKey = "inventory_lock:" + itemId; String requestId = UUID.randomUUID().toString(); try { // 获取分布式锁 boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS); if (!locked) { throw new BusinessException("系统繁忙,请重试"); } // 乐观锁更新 Inventory inventory = inventoryMapper.selectById(itemId); if (inventory.getStock() < num) { return false; } int rows = inventoryMapper.updateStock( itemId, inventory.getVersion(), inventory.getStock() - num); return rows > 0; } finally { // 释放锁 if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } }

这套系统在落地过程中,最大的收获是认识到技术方案必须紧密结合业务场景。比如我们最初采用Elasticsearch存储轨迹数据,虽然查询性能提升3倍,但维护成本增加导致最终回归MySQL方案。建议开发同类系统时,先从最小可行方案起步,逐步迭代优化。