最近常常能看到类似“基于PHP、asp.net、java、Springboot、SSM、vue3的国内新冠疫情数据分析可视化平台的设计与实现”这样的题目。第一眼确实有点唬人,好像要把六种技术栈通通塞进同一个系统里。其实做过工程的人都知道,一个后端项目不可能同时跑 Java 和 PHP,也不可能把 SSM 和 SpringBoot 混着写。真正值得思考的是:这个项目到底要解决什么问题,以及在你熟悉的语言里怎么把数据采集、存储、接口、可视化这一整条链路跑通。
这类题目最适合的场景是课程设计、毕业设计,或者想练手企业级 BI 可视化项目的人。我照着这个命题完整做了一遍,主栈选的是 SpringBoot 3 + Vue3 + ECharts + MySQL,同时对比了 SSM、PHP、ASP.NET 下的等价实现方案。这篇文章就把我拆题、建表、写接口、调图表、踩坑填坑的过程原原本本讲一遍,都是文档里不常写的实操细节。
1. 先看清题目本质:多种技术栈不是混用,而是选型对比
1.1 为什么一个“平台”题目会带这么多技术标签
这类标题通常是为了命中多个技术关键词,方便学习和检索。如果照着字面去“集成”所有框架,项目不仅难以维护,还可能被各种配置文件淹没。更合理的解读方式是:
- 后端可以选 Java 技术线,用 SpringBoot 或者 SSM 其中一种;
- 也可以选 PHP 方向,用 ThinkPHP、Laravel 或者原生 PDO 实现;
- 还可以选 ASP.NET 方向,用 .NET 8 Minimal API 来实现;
- 前端统一用 Vue3,配合 ECharts 完成数据可视化;
- 数据库用 MySQL,量大一点也可以接 PostgreSQL,但个人项目用 MySQL 最省事。
项目本质是一个“数据接入—清洗入库—统计接口—图表展示”的完整闭环。最重要的不是某种框架的语法,而是数据结构设计和统计指标口径。
1.2 六种技术栈逐一拆解
| 技术栈 | 适合场景 | 优势 | 需要注意的问题 | 本次项目定位 |
|---|---|---|---|---|
| PHP | 快速建站、小中型业务 | 部署简单,几乎处处可跑 | 弱类型导致接口数据格式容易出问题 | 备选轻量方案 |
| ASP.NET | 微软技术栈企业项目 | C# 类型安全,生态完善 | 跨平台部署需要安装 .NET 运行时 | 备选企业方案 |
| Java + SpringBoot | 企业级微服务、前后端分离 | 约定优于配置,生态成熟,资料多 | 启动占用内存稍高 | 主选后端 |
| SSM | 高校课程教学 | 分层清晰,适合理解底层过程 | XML 配置繁琐,开发效率低 | 同源对照方案 |
| Vue3 | 前端 SPA、可视化大屏 | 组合式 API 灵活,配合 Vite 快 | 对新手需要时间理解响应式原理 | 主选前端 |
| ECharts | 各类图表、地图可视化 | 配置灵活,开箱即用 | 地图数据需要额外准备 JSON | 可视化核心组件 |
我最后选择 SpringBoot + Vue3 并不是因为其他技术不行,而是这套组合在社区里案例最多,遇到地图、跨域、定时任务这类问题时能最快找到解决方案。后面我会把 SSM、PHP、ASP.NET 的等价实现思路也一起讲清楚。
1.3 从题目到模块拆解
拿到题目之后,我先把整个平台拆成四个模块:
- 数据采集模块:定时从公开数据渠道获取历史统计 JSON,经过校验后写入 MySQL。
- 数据服务模块:提供趋势、排行、地图、地区列表等统计接口。
- 管理端模块:手动触发数据同步、查看日志、维护地区映射。
- 可视化模块:Vue3 页面展示全国地图、折线图、柱状图、数据卡片。
这四个模块互相独立,所以无论后端换成哪种语言,前端 Vue3 部分和数据库结构都不需要大改。这也是“多种技术栈命题”能够成立的底层原因。
2. 数据核心:疫情数据建模与接口设计
2.1 数据来源与清洗策略
做数据分析平台,数据源是第一关。我采用的是公开渠道汇总好的结构化 JSON/CSV 数据,而不是自己从页面里写正则去抓。原因很简单:网页结构随时变,今天能抓明天就报错,与其花时间维护爬虫,不如把精力花在数据入库和历史数据的准确性上。
拿到原始数据后,需要做三步清洗:
- 统一日期格式,全部转成
yyyy-MM-dd,避免前端出现时区偏移。 - 统一地区命名,比如把“某省份”“某省”这种称呼标准化成行政区划名。
- 处理缺失值,如果某一天某个城市没有上报数据,需要决定是补 0 还是跳过。
我选择了“跳过但有记录”的策略:数据表里保留该地区当天的行,如果指标为空则存 0,前端展示时用连续日期补点。这样折线图才不会出现断裂,也不至于把缺失量误判成新增。
2.2 数据库表结构设计
数据表设计直接影响聚合 SQL 的复杂度。我没有做成一种指标一张表,而是把多个指标放在同一行,这样查询趋势时一次就能拿到确诊、治愈、死亡三个序列。
CREATE TABLE region ( id INT PRIMARY KEY AUTO_INCREMENT, province VARCHAR(40) NOT NULL, city VARCHAR(40) NOT NULL DEFAULT '', region_level TINYINT NOT NULL COMMENT '1-省级 2-市级', UNIQUE KEY uk_region_province_city (province, city) ) COMMENT '地区维度表'; CREATE TABLE daily_stat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, stat_date DATE NOT NULL, province VARCHAR(40) NOT NULL, city VARCHAR(40) NOT NULL DEFAULT '', confirmed INT NOT NULL DEFAULT 0 COMMENT '累计确诊', asymptomatic INT NOT NULL DEFAULT 0 COMMENT '累计无症状', cured INT NOT NULL DEFAULT 0 COMMENT '累计治愈', dead INT NOT NULL DEFAULT 0 COMMENT '累计死亡', source VARCHAR(255) COMMENT '数据来源标识', update_time DATETIME NOT NULL, UNIQUE KEY uk_date_province_city (stat_date, province, city) ) COMMENT '每日统计数据表';这里重点说一下为什么保存“累计值”而不是“新增值”。累计值是不可逆的存量数据,新增值可以通过当日累计减昨日累计算出来。如果你只存新增值,一旦某一天数据漏了,后续所有增量都会错位,而且历史无法回补。
2.3 聚合指标的计算口径
前端图表展示的指标需要后端统一算好,不能让每个页面自己算,否则会出现两个页面数字不一致的问题。我定义了几个通用口径:
- 今日全国新增确诊:全国当日累计确诊 − 全国昨日累计确诊
- 某省累计治愈率:该省累计治愈 / 该省累计确诊
- 某省环比变化:当日累计值 − 前一日累计值
如果某条记录的累计确诊为 0,治愈率直接返回 0,防止除零异常。
2.4 接口协议与响应结构
我把接口设计成 RESTful 风格,统一返回结构为:
{ "code": 200, "message": "success", "data": {} }主要接口如下:
| 接口 | 方法 | 作用 |
|---|---|---|
| /api/area/list | GET | 获取省、市列表 |
| /api/trend/national | GET | 获取全国累计趋势 |
| /api/trend/province?province=xxx | GET | 获取某省趋势 |
| /api/rank/province?date=xxx | GET | 获取指定日期省份排行 |
| /api/map/data | GET | 获取地图下钻数据 |
| /api/admin/sync | POST | 手动触发数据同步 |
接口设计是前后端协作的“契约”,先把契约定下来,前端就可以用 mock 数据并行开发。这也是我做过几个项目之后养成的习惯,顺序不能反,否则联调阶段会非常痛苦。
3. 后端核心实现:SpringBoot定时任务与统计接口
3.1 项目初始化与依赖选择
我用 SpringBoot 3.x + Java 17,数据库访问用 MyBatis-Plus,调度用 Spring 自带的@Scheduled。只做单实例定时任务时完全够用,不需要引入 Quartz 的额外复杂度。
pom.xml 里的关键依赖大致是:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>3.2 定时同步任务实现
数据同步用@Scheduled注解实现,每天凌晨 2 点执行一次。核心逻辑分三步:拉取数据、解析 JSON、幂等写入。
@Component public class DataSyncJob { private final DailyStatMapper statMapper; public DataSyncJob(DailyStatMapper statMapper) { this.statMapper = statMapper; } @Scheduled(cron = "0 0 2 * * ?") @Transactional(rollbackFor = Exception.class) public void syncDailyData() { String json = remoteDataClient.fetch(); List<DailyStat> list = jsonParser.parse(json); // 先按统计日期删除,防止重复执行造成数据翻倍 statMapper.deleteByDate(LocalDate.now()); for (DailyStat stat : list) { statMapper.insert(stat); } } }这里最关键的是幂等性:如果任务因为异常重复执行,也不会出现同一天同一地区两条记录。我在daily_stat表上加了uk_date_province_city唯一索引,代码里再配合“先删后插”,双保险。
3.3 全国趋势接口与聚合 SQL
全国累计趋势本质上是对省级数据按日期分组求和:
@Mapper public interface DailyStatMapper { @Select("SELECT stat_date, " + "SUM(confirmed) AS confirmed, " + "SUM(cured) AS cured, " + "SUM(dead) AS dead " + "FROM daily_stat " + "WHERE city = '' " + "GROUP BY stat_date ORDER BY stat_date") List<TrendVO> selectNationalTrend(); }只取city = ''的行,是因为我入库时省级记录的城市字段为空字符串。同理,查某省趋势时加一个province = #{province}条件即可。
实际项目里数据量并不大,即使是全国按天积累几年,也就几千行,MySQL 完全扛得住。但我还是在stat_date和province上建了联合索引,因为接口会反复按日期和地区查询。
3.4 用 Spring Cache 给图表接口加速
疫情数据本身不是高频变化数据,一小时甚至一天更新一次就足够了。所以我给统计接口加了一层本地缓存:
@Cacheable(cacheNames = "trend:national", unless = "#result == null") public TrendVO nationalTrend() { return statMapper.selectNationalTrend(); }在手动同步数据成功后,调用@CacheEvict把相关缓存清掉,下一次请求就能拿到新数据。直接用 Caffeine 本地缓存就够了,没必要为个人项目引入 Redis。
3.5 换成 SSM、PHP、ASP.NET 后的等价实现
很多人会纠结这几种后端技术到底有什么不同,我可以直接给结论:业务逻辑完全一样,换的只是框架语法。
- SSM 版本:把 SpringBoot 的自动配置拆回 XML 和注解扫描,Mapper 改用 XML 文件写 SQL,定时任务用 Spring 的任务命名空间配置。代码分层更重,但压力测试和调试成本更高。
- PHP 版本:用 ThinkPHP 6 的
Console命令做定时同步,控制器返回 JSON,使用 PDO 预处理防止注入。PHP 部署方便,但接口里整数和字符串的边界要特别注意,否则前端图表可能拿到字符串导致计算异常。 - ASP.NET 版本:用 .NET 8 Minimal API + EF Core,定时任务用
BackgroundService,LINQ 写聚合查询非常顺手。缺点是 Linux 上部署要额外安装运行时,或者发布为自包含程序,包体较大。
这些方案的共同点是:表结构不变、接口契约不变、前端页面不变。所以真正值钱的部分是数据建模和指标口径,而不是选了什么语言。
4. 前端可视化:Vue3 + ECharts 搭建疫情看板
4.1 Vue3 项目初始化与目录划分
前端我使用 Vite 创建项目,并安装了 vue-router、pinia、axios 和 echarts。目录结构按功能分块:
src/ api/ # 所有后端接口请求封装 assets/map/ # 全国地图 JSON components/ # 图表组件 views/ dashboard/ # 总览看板 store/ # Pinia 状态 utils/ # echarts 封装为什么不把所有图表写在一个页面文件里?因为疫情看板至少包含地图、趋势图、排行榜、数据卡片四类组件,拆开后每个组件只负责一件事,后续维护和复用都轻松。
4.2 全国地图下钻实现
地图是可视化平台的门面,也是新手出错最多的地方。ECharts 本身不提供地图数据,需要准备一份全国行政区划 GeoJSON,然后通过registerMap注册。
import * as echarts from 'echarts'; import regionGeo from '@/assets/map/region.json'; echarts.registerMap('region', regionGeo);核心配置:
const option = { tooltip: { trigger: 'item', formatter: (params) => { return `${params.name}<br/>累计确诊:${params.value ?? '暂无'}` } }, visualMap: { min: 0, max: 5000, text: ['高', '低'], inRange: { color: ['#e0f3f8', '#fee090', '#fc8d59', '#d73027'] } }, series: [{ type: 'map', map: 'region', roam: true, label: { show: true }, data: mapData }] };visualMap会自动根据值的大小映射颜色深浅,不需要手动设置每个地区的itemStyle,这个配置是地图“色阶图”的关键。地图点击事件用来下钻:
chart.on('click', (params) => { router.push({ path: '/province', query: { name: params.name } }); });4.3 趋势图与排行图的细节处理
趋势图我用了双 Y 轴方案,因为累计确诊和治愈、死亡数值不在一个量级,如果共用一根轴,后两个曲线会贴着底部,几乎看不出变化:
const option = { legend: {}, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: dates }, yAxis: [ { type: 'value', name: '确诊', position: 'left' }, { type: 'value', name: '治愈/死亡', position: 'right' } ], series: [ { name: '累计确诊', type: 'line', yAxisIndex: 0, data: confirmedList }, { name: '累计治愈', type: 'line', yAxisIndex: 1, data: curedList }, { name: '累计死亡', type: 'line', yAxisIndex: 1, data: deadList } ] };排行榜用横向柱状图,按省份某一天的新增数量排序,只展示前 15 位。该页面的数据不需要实时轮询,我设置了一个 5 分钟定时刷新,避免给后端接口造成不必要的压力。
4.4 封装一个 useEChart 组合式函数
在 Vue3 里,最规范的做法是把图表的初始化、自适应、销毁都封装到一个组合式函数里:
export function useEChart(containerRef) { let chart = null; const initChart = () => { if (!containerRef.value) return; chart = echarts.init(containerRef.value); window.addEventListener('resize', () => chart?.resize()); }; const setOption = (option) => { if (!chart) initChart(); chart.setOption(option); }; const disposeChart = () => { window.removeEventListener('resize', () => chart?.resize()); chart?.dispose(); chart = null; }; onMounted(initChart); onBeforeUnmount(disposeChart); return { initChart, setOption }; }这里必须强调:echarts.init只能在 DOM 渲染完成后调用。如果你在setup里直接初始化,容器的宽度是 0,图表会显示空白。所有图表组件我都配合nextTick或onMounted使用。
4.5 跨域配置与 Nginx 部署
开发环境用 Vite 代理解决跨域:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }生产环境用 Nginx 同时托管前端静态资源并反向代理后端接口:
server { listen 80; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }最后一行try_files是处理 Vue Router history 模式的关键,否则刷新子路由页面会 404。
5. 排雷记录:疫情可视化项目最常见的6个问题
5.1 地图省份名称对不上,区域显示空白
这是我第一次联调地图时最头疼的问题。ECharts 地图取值后面的name字段,而后端数据库里保存的可能是简称或者带别名的地区名,两边匹配不上,地图就默认为 0,显示一片空白。
解决思路是维护一张地区映射表,或者在后端返回时统一把province字段映射成地图 JSON 里的标准名称。简单做法是代码里做一次normalizeRegionName替换,去掉“省”“市”等后缀再匹配。
5.2 定时任务重复执行,数据翻倍
Spring 的@Scheduled在单机模式下没问题,但如果在多实例部署情况下,每个实例都会执行一次任务,导致重复入库。即使单机运行,也可能因为手动调度和自动调度重叠而触发重复写入。
我的处理方法有两个:一是数据库唯一索引兜底,二是写入前先按日期删除。更严谨的团队可以用 ShedLock 或数据库锁表,个人项目没必要把复杂度拉满。
5.3 图表初始化时宽度为 0,渲染空白
图表容器如果被v-if控制,或者父组件还在异步加载数据时,容器还没出现在 DOM 里。直接在onMounted里初始化,很容易拿到 0 宽高。
解决方法是把初始化放到数据请求完成后的nextTick中,同时给容器设置一个固定高度,比如style="height: 420px"。宽度最好由父容器决定,不要在option里硬编码。
5.4 接口数据被前端时区转换“偷走”了一天
后端如果返回2024-01-01T00:00:00+08:00这种带时区的字符串,前端new Date()再格式化,可能渲染成前一天。尤其是部署到境外服务器时,时区问题非常容易踩。
我最终统一约定:后端接口日期返回yyyy-MM-dd纯字符串,前端不做任何时区转换,直接展示。这个约定从源头上消灭了时间偏移导致的“曲线断层”。
5.5 接口跨域通了,但请求头带不上 Token
开发环境配了 Vite 代理后,跨域一般不是问题。但如果直接从前端地址访问后端地址,并且需要自定义 Header 时,后端要支持 CORS:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }如果设置了allowCredentials(true),就不能用*作为允许来源,要写成具体的访问域名,这一点很容易被忽略。
5.6 性能问题速查表
| 场景 | 现象 | 处理方案 |
|---|---|---|
| 全国趋势接口反复查询 | 数据库 CPU 升高 | 增加 Spring Cache 缓存,缓存时间 30 分钟 |
| 地图数据量过大 | 前端渲染卡顿 | 首次加载只取省级数据,点击后再取市级 |
| 排行接口慢 | SQL 扫描全表 | 联合索引优化,按日期先过滤 |
| 同步日志太多 | 日志文件膨胀 | 按天滚动输出,只保留 30 天 |
6. 部署上线与扩展思路
6.1 前后端打包部署流程
前端执行npm run build生成dist目录,后端执行mvn package生成 fat jar。部署时两者可以放在同一台服务器:
# 简单启停脚本 java -jar covid-dashboard.jar > app.log 2>&1 &我建议把前端静态文件放到 Nginx 的 HTML 目录,后端 jar 单独放在/app目录,并用systemd管理进程。这样重启前端只需要 reload Nginx,重启后端只需要重启服务,互不干扰。
6.2 把疫情平台泛化成通用数据可视化基座
做完这个项目后我发现,真正可复用的不是疫情数据,而是“数据接入—清洗—存储—聚合—展示”的框架。如果后续想扩展成商品销量分析平台或者城市基础设施监控平台,只需要做两件事:
- 替换数据采集模块,把“拉取疫情 JSON”换成新数据源;
- 更换指标配置,把确诊、治愈、死亡替换成销量、库存、退货等业务指标。
Vue3 和 ECharts 部分几乎不需要动,因为图表的输入始终是“日期 + 地区 + 多个数值字段”。
6.3 我做完整个项目的几点体会
我最初是用 SSM 写的第一版,后来重构到 SpringBoot,最大的感觉是:框架只是工具,数据建模和接口设计才是核心。SSM 里大量时间花在 XML 配置和环境搭建上,SpringBoot 把注意力拉回到业务本身。前端地图匹配问题反而花的时间最多,因为地区命名不一致这类脏数据问题,不是靠写代码能自动解决的,必须反复比对。
最后说一个很实用的小技巧:每个定时同步任务执行时,把拉取到多少条、入库成功多少条、失败多少条全部打印到日志。别小看这几行日志,一旦图表数据对不上,翻日志定位是最快的,否则你只能对着数据库和前端页面瞎猜。做数据可视化项目,先把数据质量守住,图表怎么画都是好看的。