简介:面向Java Web课程设计与SSM框架初学者的疫情数据统计分析系统完整源码,可支撑疫情数据的录入、查询、图表展示等核心业务。后端采用Spring+SpringMVC+Mybatis,前端为JSP页面,配套MySQL 5.7脚本,基于JDK1.8与IDEA环境即可运行。压缩包共67个文件,容量约1.02MB,包含23个Java后台类、13个JSP动态页面、10个JavaScript交互脚本、6个CSS样式表,以及SQL初始化脚本、MyBatis映射XML和项目说明文档,模块划分明确,适合直接导入IDE开展二次开发。目前已有393人学习下载。读者既可借此完整走通SSM三层架构的请求-处理-响应流程,也可研究数据统计的SQL聚合逻辑与前端图表联动方案,还能参考其包结构快速套用到其他管理类课程设计。
1. 疫情数据统计系统用 SSM 而不是 Spring Boot,图的是一套看得见的请求链路
在课程设计里交一个疫情数据统计分析系统,最忌讳的是把 Spring Boot 当黑盒用:启动类一跑,所有东西都自动装配好,评委问 DispatcherServlet 在哪、SqlSessionFactory 什么时候创建,答不上来。SSM 框架的好处是 Spring 管 Bean、SpringMVC 管路由、MyBatis 管 SQL,每一层都显式配置,出问题能顺着日志一层层查。这个项目包括疫情数据录入、疫情数据查询和图表展示,核心指标是确诊、疑似、治愈、死亡,按省份和日期记录。适合 javaweb 课程设计参考,也适合刚学完 SSM 整合但没做过完整 CRUD 的人拿源码对照着改。后面按表结构、录入事务、动态查询、ECharts 对接、运行排错这几条线讲清楚。
2. 疫情数据表结构与 MyBatis 持久层:先定唯一键再谈统计
2.1 每日疫情统计表的字段设计
2.1.1 地区粒度怎么定
疫情数据统计分析系统的第一个设计难点是地区粒度。如果只存省份,想按城市分析时没有扩展余地;如果每个城市都存,录入工作量会翻倍,而且很多城市平时根本没有数据,表里会堆大量全是零的记录。常见做法是表上留 city 字段,省级数据写空字符串,城市级数据再填市级名称。这样查询时既可以用 GROUP BY province 看省级汇总,也可以用 GROUP BY city 看城市维度,一张表同时满足两级粒度。
课程设计如果明确只做省份维度,city 可以完全去掉,但保留它扩展价值更大。唯一键不是 id,而是 province、city、record_date 三列组合,这个设计直接决定了后面录入时能不能用一条 SQL 完成“存在则更新、不存在则插入”,所以建表时要一次想清楚。
2.1.2 字段、类型与索引
CREATE TABLE epidemic_data ( id INT NOT NULL AUTO_INCREMENT COMMENT '主键', province VARCHAR(50) NOT NULL COMMENT '省份名称', city VARCHAR(50) NOT NULL DEFAULT '' COMMENT '城市名称,省级数据时为空字符串', confirm_case INT NOT NULL DEFAULT 0 COMMENT '确诊人数', suspect_case INT NOT NULL DEFAULT 0 COMMENT '疑似人数', cure_case INT NOT NULL DEFAULT 0 COMMENT '治愈人数', death_case INT NOT NULL DEFAULT 0 COMMENT '死亡人数', record_date DATE NOT NULL COMMENT '数据所属日期', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后修改时间', PRIMARY KEY (id), UNIQUE KEY uk_province_city_date (province, city, record_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疫情每日统计';这段 SQL 解决三个实际问题。第一,唯一键防止同一天同一个地区被重复提交,页面连点两次按钮也不会产生脏数据。第二,confirm_case等人数字段用 INT 存储,非负判断放到 Service 层,数据库层不用 CHECK 约束,避免 MySQL 5.7 对 CHECK 支持不完善带来的兼容问题。第三,字符集用 utf8mb4,省份名称不会乱码,以后要加备注字段也能直接存中文标点。InnoDB 提供事务和行级锁,多个管理员同时录入不同省份数据时,不会出现一个插入操作锁住整张表的情况。
| 字段 | 类型 | 说明 |
|---|---|---|
| province | VARCHAR(50) | 省份名称,查询条件里的高频字段 |
| city | VARCHAR(50) | 城市,默认空串表示省级 |
| record_date | DATE | 统计日期,参与唯一键且被 ORDER BY |
| confirm_case | INT | 确诊人数,累计口径 |
| suspect_case | INT | 疑似人数,累计口径 |
| cure_case | INT | 治愈人数,累计口径 |
| death_case | INT | 死亡人数,累计口径 |
| update_time | TIMESTAMP | 写入或更新时自动维护 |
这个字段列表不是越多越好。课程设计阶段不建议加 area_code、source、remark 这类字段,除非页面确实需要,否则录入表单会空一大片,校验逻辑也被迫处理大量可以不提交的值。统计类系统里的字段应当是“每个字段都有计算或筛选出口”,加进去就要能查到、能展示、能聚合。
2.2 MyBatis 的 insert 与 update 合并写法
2.2.1 Mapper 接口定义
单独写 insert 和 update 会导致 Service 层先查一次再判断是新增还是修改,代码多,而且并发下第一次查询结果可能不准确,造成唯一键冲突异常。我一般用一个方法同时处理,MyBatis 接口定义如下:
public interface EpidemicDataMapper { // 存在同一天同地区数据时更新人数,否则插入新记录 int insertOrUpdate(EpidemicData data); // 多条件组合查询,对应列表页 List<EpidemicData> selectByCondition(EpidemicQuery query); // 按日期聚合,对应图表页 List<Map<String, Object>> selectDailySummary(EpidemicQuery query); }insertOrUpdate在底层利用 MySQL 的ON DUPLICATE KEY UPDATE语法,命中了唯一键就更新人数字段,没命中就插入。Service 层不需要关心这条数据是否已经存在,只要把页面上拿到的对象传给 Mapper 就行。selectByCondition对应后面的多条件查询,selectDailySummary对应图表的聚合数据,三个方法覆盖录入、列表、图表三类核心需求,后续加删除或导出功能时再补。
2.2.2 Mapper XML 中的唯一键冲突处理
<insert id="insertOrUpdate" parameterType="com.example.entity.EpidemicData"> <!-- 利用 uk_province_city_date 唯一键合并新增和更新 --> INSERT INTO epidemic_data (province, city, confirm_case, suspect_case, cure_case, death_case, record_date) VALUES (#{province}, #{city}, #{confirmCase}, #{suspectCase}, #{cureCase}, #{deathCase}, #{recordDate}) ON DUPLICATE KEY UPDATE confirm_case = VALUES(confirm_case), suspect_case = VALUES(suspect_case), cure_case = VALUES(cure_case), death_case = VALUES(death_case) </insert>VALUES()函数在 MySQL 5.7 里取的是本次插入语句的原始值,不是库里已有的旧值,也就是说后提交的数据会覆盖先提交的同一天同一地区数据。对于课程设计的录入场景,这种“覆盖式更新”更符合预期。如果你希望只更新不为空的字段,比如只改治愈人数不动确诊人数,就不能这样写,得在 Service 层先查旧值再合并。MyBatis 参数用#{property}形式,实体类字段名是驼峰,JSP 表单的 name 也和实体属性对齐,省掉一层手工 setter 转换。
3. 疫情数据录入:表单绑定、Service 校验与事务回滚
3.1 JSP 表单到 Controller 的参数绑定
3.1.1 表单 name 必须与实体属性对齐
疫情数据录入页面通常放四个数字输入、一个省份输入和一个日期选择。JSP 端用 Post 提交,form 的 action 指向/epidemic/save。这里最容易出问题的是日期格式:浏览器input type="date"输出的是yyyy-MM-dd,而 Java 实体里的recordDate如果是java.util.Date,SpringMVC 默认无法把字符串转成 Date,必须在实体字段或控制器参数上加@DateTimeFormat。
<form action="${pageContext.request.contextPath}/epidemic/save" method="post"> <!-- 省份名称必填,city 用 hidden 提交空串代表省级数据 --> <input type="text" name="province" placeholder="省份" /> <input type="hidden" name="city" value="" /> <input type="date" name="recordDate" /> <input type="number" name="confirmCase" min="0" /> <input type="number" name="suspectCase" min="0" /> <input type="number" name="cureCase" min="0" /> <input type="number" name="deathCase" min="0" /> <button type="submit">提交</button> </form>这个表单里的province、recordDate、confirmCase等 name 值必须和实体类 EpidemicData 的属性名完全一致,SpringMVC 才能用反射完成自动绑定。city用 hidden 是因为当前设计只做省级统计,但它还是实体的一部分,提交时传空字符串,正好命中数据库唯一键里的“省级空串”。如果不传这个隐含字段,city 会是 null,和库里的空串语义不一致,不会触发唯一键冲突,数据就会重复插入。
3.1.2 Controller 层只做绑定不做业务
Controller 不写太多业务判断,只负责把参数对象交给 Service。日期绑定失败时 SpringMVC 在绑定阶段抛 TypeMismatchException,可以在 Controller 里使用 BindingResult 捕捉,但课程设计里更简单的是在 Service 层对非空和非法值做防御式校验。Controller 方法大概长这样:
@RequestMapping("/epidemic/save") public String save(@RequestParam("recordDate") @DateTimeFormat(pattern = "yyyy-MM-dd") Date recordDate, EpidemicData data, Model model) { data.setRecordDate(recordDate); epidemicService.saveEpidemicData(data); return "redirect:/epidemic/list"; }@DateTimeFormat(pattern = "yyyy-MM-dd")写在@RequestParam参数上,只对这一个日期参数生效;也可以用当前时间再用 setter 赋给实体。重定向到 list 而不是转发,是为了避免用户刷新页面时重复提交上一次的表单。
| 参数 | 来源 | 校验点 |
|---|---|---|
| province | 表单文本框 | 非空、去掉首尾空格 |
| recordDate | 日期选择器 | 非空、格式 yyyy-MM-dd |
| confirmCase 等 | number 输入 | 非负、不超过 1000000 |
| city | hidden | 可为空,空串入库 |
这里的校验规则要和数据库唯一键配合理解:数据库唯一键是最后一道防线,程序校验是第一道防线。页面 number 输入框加了min="0",但那是浏览器端的,绕过页面直接 Post 请求照样能传负数,所以 Service 层必须再来一次。
3.2 Service 层校验与事务边界
3.2.1 一条方法里完成校验和入库
@Override @Transactional(rollbackFor = Exception.class) public void saveEpidemicData(EpidemicData data) { // 防御式校验:页面可能被绕过,Service 层必须再拦一次 if (data.getProvince() == null || data.getProvince().trim().length() == 0) { throw new IllegalArgumentException("省份不能为空"); } if (data.getRecordDate() == null) { throw new IllegalArgumentException("统计日期不能为空"); } int[] nums = new int[] { data.getConfirmCase(), data.getSuspectCase(), data.getCureCase(), data.getDeathCase() }; for (int num : nums) { if (num < 0) { throw new IllegalArgumentException("人数不能为负数"); } } data.setProvince(data.getProvince().trim()); epidemicDataMapper.insertOrUpdate(data); }@Transactional(rollbackFor = Exception.class)的作用是:当方法抛出 Exception 及其子类时,把当前线程绑定的事务回滚,不只是回滚 RuntimeException。这里有一个高频坑是自调用问题——如果另一个方法在同一个类里直接调用this.saveEpidemicData(data),事务注解不会生效,因为 Spring 的事务基于代理对象实现,只有从外部通过代理进入的方法才会被拦截。常见错误是 Controller 里直接调本类 Service 方法,却没让 Service 实现类被 Spring 管理。
3.2.2 批量录入时的事务边界
批量导入场景下,有人会把循环写在 Controller 里,每调一次 saveEpidemicData 就提交一次事务。这样第二批数据一旦失败,第一批已经提交,表里出现半批数据,统计图表拿到不完整结果。正确做法是提供一个批量保存的 Service 方法,让整个循环处于同一个事务:
@Override @Transactional(rollbackFor = Exception.class) public void batchSave(List<EpidemicData> list) { if (list == null || list.isEmpty()) { return; } // 循环里的所有 insertOrUpdate 都在 batchSave 的事务内 for (EpidemicData data : list) { saveEpidemicData(data); } }注意batchSave是外部进入的代理方法,它内部调用this.saveEpidemicData时,saveEpidemicData 上的注解不生效,但因为 batchSave 本身挂了事务,循环里的每次 insert 都在同一个事务里执行,任何一条校验失败都会让整个批量导入回滚。人工录入通常是单条提交,批量只出现在 Excel 导入或测试数据初始化,事务在这里的价值是完全一致的。
4. 疫情数据查询:动态 SQL 组装与 PageHelper 分页
4.1 查询条件封装成对象
4.1.1 为什么用一个查询对象承载全部条件
查询页面通常有省份、日期范围、确诊人数下限三个条件,用户可能只填一个,也可能三个都填。如果为每种组合写一个查询方法,组合数会随条件数量指数增长,维护起来非常差。MyBatis 的动态 SQL 解决的就是这种“条件可变”的问题:用一个查询对象承载所有可选条件,XML 里用<where>加<if>判断哪些条件需要拼进 SQL。查询对象 EpidemicQuery 的字段如下:
public class EpidemicQuery { private String province; // 省份条件,为空则不参与拼接 private Date startDate; // 开始日期 private Date endDate; // 结束日期 private Integer minConfirm; // 最小确诊人数,null 表示不限制 private Integer pageNum = 1; // 当前页码 private Integer pageSize = 10; // 每页条数 // getter/setter 省略 }minConfirm是 Integer 而不是 int,原因是 int 有默认值 0,会导致用户没填这个条件时也被拼成confirm_case >= 0,虽然结果一样,但看不清真正的条件拼接逻辑。用 Integer 包装类型,没填时为 null,<if>判断直接跳过,SQL 日志里也能清楚地看到用户到底查了什么条件。
4.1.2 Mapper XML 动态 SQL
<select id="selectByCondition" resultType="com.example.entity.EpidemicData"> SELECT id, province, city, confirm_case AS confirmCase, suspect_case AS suspectCase, cure_case AS cureCase, death_case AS deathCase, record_date AS recordDate FROM epidemic_data <where> <!-- 条件为空时 <where> 会自动去掉第一个 AND --> <if test="province != null and province != ''"> AND province = #{province} </if> <if test="startDate != null"> AND record_date >= #{startDate} </if> <if test="endDate != null"> AND record_date <= #{endDate} </if> <if test="minConfirm != null"> AND confirm_case >= #{minConfirm} </if> </where> ORDER BY record_date DESC, province ASC </select><where>标签会自动去掉条件片段开头的第一个 AND,避免生成WHERE AND province = ?这种语法错误。日期范围条件用>=和<=,因为 XML 里不能直接写大于号和小于号,这是 MyBatis 配置里最容易踩的坑。排序固定为日期倒序、省份正序,让最新数据排在最前面。这里没有给分散的order by加 if,是因为列表页排序规则固定,用户不需要动态选择。
| 条件 | 对应 SQL 片段 | 判断依据 |
|---|---|---|
| 省份 | AND province = #{province} | 非空字符串 |
| 开始日期 | AND record_date >= #{startDate} | startDate 非 null |
| 结束日期 | AND record_date <= #{endDate} | endDate 非 null |
| 最小确诊 | AND confirm_case >= #{minConfirm} | minConfirm 非 null |
| 排序 | ORDER BY record_date DESC | 固定 |
4.2 分页查询与总记录数
4.2.1 PageHelper 的用法和坑
分页不能靠 LIMIT 写死,课程设计里常用 PageHelper 插件。在 pom 引入 pagehelper 依赖后,查询前调用PageHelper.startPage(pageNum, pageSize),紧接着的一条 Mapper 查询会被自动拦截并拼接 LIMIT,同时自动执行 count 查询拿到总记录数。
public PageInfo<EpidemicData> queryPage(EpidemicQuery query) { // startPage 后必须是第一条 Mapper 查询,中间不能参杂其他 SQL PageHelper.startPage(query.getPageNum(), query.getPageSize()); List<EpidemicData> list = epidemicDataMapper.selectByCondition(query); return new PageInfo<>(list); }PageHelper.startPage和selectByCondition之间不能有任何别的查询,否则拦截会落到前一条查询上,导致分页不生效。startPage只对下一条 SQL 生效,不能在循环里反复调用。页面拿到 PageInfo 后,用getList()展示数据行,getTotal()显示总条数,getPages()计算总页数。分页跳转时,要把 province、startDate、endDate 这些查询条件一起作为参数传到下一页,否则第二页会丢掉筛选条件,看起来像是分页功能坏了。
5. 图表展示:Controller 返回聚合 JSON 与 ECharts 折线图对接
5.1 按日期聚合的查询接口
5.1.1 在 SQL 层聚合而不是在 Java 里循环
列表页展示的是明细,图表页要的是趋势。趋势图的 X 轴是日期,Y 轴是确诊、治愈、死亡曲线。如果先把所有明细数据查出来,再在 Java 里按日期累加,代码长且数据量大。更合适的做法是让 MySQL 完成分组聚合,Controller 直接返回由 Map 组成的 List,前端拿到 JSON 就能渲染。
@RequestMapping("/epidemic/trend") @ResponseBody // 前端传省份名,返回该省按日期的聚合结果 public List<Map<String, Object>> trend(String province) { if (province == null || province.trim().isEmpty()) { return Collections.emptyList(); } EpidemicQuery query = new EpidemicQuery(); query.setProvince(province.trim()); return epidemicDataMapper.selectDailySummary(query); }这里没有走 Service,是因为聚合查询没有太多业务逻辑可加,直接调 Mapper 可以少一层包装。如果后面要加缓存或权限判断,再补 Service 层也不迟。Collections.emptyList()返回空集合而不是 null,避免前端把 null 当数组处理时报错。
5.1.2 聚合 SQL 的 resultType 和别名
<select id="selectDailySummary" resultType="map"> <!-- recordDate 用 AS 别名,前端 JS 直接取 res[i].recordDate --> SELECT record_date AS recordDate, SUM(confirm_case) AS confirmCase, SUM(suspect_case) AS suspectCase, SUM(cure_case) AS cureCase, SUM(death_case) AS deathCase FROM epidemic_data WHERE province = #{province} GROUP BY record_date ORDER BY record_date ASC </select>resultType="map"让每行结果变成一个 Map,key 是 SQL 里的别名。别名用驼峰,前端拿data.confirmCase比拿data.confirm_case更自然。record_date保持 DATE 类型,Jackson 序列化默认输出yyyy-MM-dd,正好是 ECharts 能直接识别的时间字符串。如果数据库存在缺某天的数据,这条 SQL 只会返回有记录的日期,前端折线图会自动跳过空档。
5.2 ECharts 加载与更新
5.2.1 前端请求 JSON 并填充 option
页面用 JSP 渲染,数据用 AJAX 动态加载。先放一个带固定高度的 div 作为图表容器,再初始化 ECharts 实例,请求成功后用 setOption 更新:
var chart = echarts.init(document.getElementById('trendChart')); // 请求聚合接口,遍历结果组装折线图数据 $.post(contextPath + '/epidemic/trend', { province: $('#provinceSel').val() }, function(res) { var dates = []; var confirmData = []; var cureData = []; for (var i = 0; i < res.length; i++) { dates.push(res[i].recordDate); confirmData.push(res[i].confirmCase); cureData.push(res[i].cureCase); } chart.setOption({ tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: dates }, yAxis: { type: 'value' }, series: [ { name: '确诊', type: 'line', data: confirmData }, { name: '治愈', type: 'line', data: cureData } ] }); });res[i].recordDate对应 SQL 里的别名 recordDate,res[i].confirmCase对应聚合列别名 confirmCase,大小写必须一致,否则图表里全是 undefined。trigger: 'axis'让鼠标悬停在某个日期点时横坐标触发提示框,比较适合连续日期趋势。前端用$.post时,数据默认格式是 key=value,Controller 方法参数名province要和这里请求参数名一致。
5.2.2 series 配置要点
| 配置项 | 值 | 作用 |
|---|---|---|
| type | line | 折线图,适合日期趋势 |
| smooth | true/false | 是否使用曲线拟合 |
| stack | 不设置 | 多个指标不叠加时用普通折线 |
| connectNulls | true | 某天缺数据时用直线连接 |
折线图和柱状图的选择标准是指标数量。单指标用柱状图更直观;确诊、治愈、死亡三个指标一起看时折线图不会互相遮挡。如果数据按城市维度录入,某几天城市数据缺失,connectNulls要设成 true,否则趋势线会断开,看起来像数据丢失而不是当天没有数据。
6. 运行排错与统计口径:从 404 到图表空白逐项排查
6.1 三个最高频的启动和查询报错
用 IDEA 运行 SSM 项目,先确认 pom.xml 里打包方式和 Tomcat 插件适配 JDK 1.8。启动后 404,先看控制台是否打印Mapping servlet: 'dispatcherServlet',没有就检查 web.xml 里 DispatcherServlet 的 url-pattern;有但查不到数据,去看 Mapper XML 的 namespace 是否和接口全限定名一致。数据库连不上时,重点检查 JDBC URL 是否带了useUnicode、characterEncoding和serverTimezone。
jdbc:mysql://localhost:3306/epidemic_maven?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai mvn clean package -DskipTestsmvn clean package会在 target 目录产出 war 包,可以直接放到 Tomcat 的 webapps 下运行。JDBC URL 里不写 serverTimezone,MySQL 驱动容易出现时区偏移 8 小时或直接拒绝连接的问题。看到CLIENT_PLUGIN_AUTH is required时,说明 mysql-connector-java 版本和 MySQL 服务端版本不匹配,换成与 MySQL 5.7 配套的驱动版本即可,不要停留在旧版兼容写法上。
6.2 图表空白先看 Network,再看聚合口径
前端图表空白并不一定是后端接口坏了。打开浏览器开发者工具的 Network 面板,看/epidemic/trend的响应状态码和 JSON 内容:如果是 404,检查 Controller 的 @RequestMapping 路径和前端请求路径是否有项目上下文差异;如果是 200 但 data 为空数组,说明数据库里没有匹配省份的数据;如果 JSON 正常但图还是空白,问题多半在 ECharts 容器高度上,div 没有显式 height,图表初始化高度为 0,表现为页面有图例但没有坐标轴。
统计口径方面,这个系统的核心是把“累计值”和“新增值”区分清楚。表里的 confirmCase 通常是累计确诊人数,前端折线图展示的也就是累计趋势;如果用户想看到每日新增曲线,需要在图表接口里用当前日期减去前一天的值,不能在 Mapper 里简单 SUM。课程设计答辩时,主动说明这套口径差异并给出应对方案,比反复放页面截图更有说服力。
本文还有配套的精品资源,点击获取