Spring Boot + ECharts 新闻可视化平台:从数据库到图表的完整实现
2026/9/14 8:02:35 网站建设 项目流程

简介:一套基于Spring Boot和ECharts的新闻可视化分析平台源码,同时附带数据库脚本与CSV样例数据,专为需要完成新闻数据分析类课程设计或毕业设计的开发者准备。整个项目打通了Spring Boot后端服务、Spring Data JPA数据访问、ECharts前端图表展示三个环节,可帮助读者快速理解Java Web项目中后端接口与前端可视化组件的协作方式。压缩包约1.6MB,共57个文件,收录17个Java源码、6个JavaScript脚本、4个XML配置、1个SQL脚本,以及HTML页面、CSS样式、字体、图片和说明文档等,目录划分清楚,适合对照学习或二次改造。目前已有388人学习关注,通过实际编译运行可掌握Spring Boot自动配置、Starter依赖、Repository接口以及ECharts折线图/柱状图/饼图的交互配置等核心技能,是一份实践性较强的完整项目参考。

1. 新闻可视化平台的真问题:Spring Boot 负责统计口径,ECharts 只认数组

“基于 Spring Boot + ECharts 的新闻可视化分析平台”听起来是采集新闻、展示图表,但真正动手做一遍会发现,核心工作量根本不在收集新闻,而在把新闻数据整理成前端能直接消费的统计口径。Spring Boot 在这里不是业务系统,它只做一件事:接收查询条件,返回按时间、来源、分类聚合好的 JSON;ECharts 也不关心数据来自哪里,它只认 x 轴数组、series 数组和键值对对象。这三层各管各的,只要数据链路是通顺的,平台就成立。这套技术栈也是课程设计和 java 毕设里最高频的组合之一:数据库管存储、Spring Boot 管接口、ECharts 管展示,每一层都能单独验证。下文按「数据模型 → 聚合接口 → 图表渲染 → 造数与验证」的顺序,把这套方案完整拆开,代码可以直接抄。

2. 数据库设计:新闻表字段怎么定,聚合 SQL 才好写

2.1 为什么用宽表而不是教科书式的多表范式

新闻业务按教科书建模往往要拆出新闻表、分类表、来源表、作者表,再通过外键关联。但可视化分析平台最高频的操作是“按天统计点击量”“按来源算占比”“按分类看分布”,这些都要把多张表 join 在一起做 GROUP BY,SQL 写得越长,索引越难命中,排查问题也越麻烦。常见做法是把分类名、来源名直接冗余成字符串字段,只保留一张新闻主表和一张可选的分类字典表。

这样做的好处有两个:一是插入数据时不需要维护外键,造数脚本和爬虫写入都简单;二是聚合查询全部落在单表上,执行计划清晰可控。代价是冗余字段偶尔会出现写不一致,但分析型平台的数据是只读的,更新场景极少,这个代价可以接受。

2.2 news_article 建表 SQL 与关键索引说明

下面是一张可以直接运行的新闻主表,字段覆盖标题、摘要、来源、分类、地域、发布时间和互动指标:

CREATE TABLE news_article ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL COMMENT '新闻标题', summary VARCHAR(1000) DEFAULT '' COMMENT '新闻摘要', source_name VARCHAR(64) NOT NULL COMMENT '来源媒体名称', category_name VARCHAR(32) NOT NULL COMMENT '分类:科技/财经/体育等', province VARCHAR(32) DEFAULT NULL COMMENT '涉及地域,用于地图展示', publish_time DATETIME NOT NULL COMMENT '精确发布时间', publish_date DATE NOT NULL COMMENT '按天聚合冗余字段', clicks INT DEFAULT 0 COMMENT '点击量', likes INT DEFAULT 0 COMMENT '点赞量', KEY idx_date_category (publish_date, category_name), KEY idx_source_date (source_name, publish_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='新闻文章表';

关键点全在最后两个索引和 publish_date 字段上。按天聚合时如果使用DATE(publish_time),MySQL 会因为函数包裹列而放弃索引,必须全表扫;冗余一个 publish_date 后,直接GROUP BY publish_date,idx_date_category 能完整命中筛选加排序。需要按来源过滤再按时间排序时,走的是 idx_source_date。

对应数据字典如下:

字段名类型说明
idBIGINT主键,自增
titleVARCHAR(255)标题,列表页直接展示
summaryVARCHAR(1000)摘要,词云和详情页复用
source_nameVARCHAR(64)来源媒体,可加索引做筛选项
category_nameVARCHAR(32)新闻分类,饼图按它分组
provinceVARCHAR(32)仅当需要地图时使用,可留空
publish_timeDATETIME精确时间,详情页展示用
publish_dateDATE冗余日期,趋势图按它聚合
clicks / likesINT互动指标,折线图的值来源

2.3 数据量上去之后:再加一张聚合统计表

当单表超过百万行、前端又要求秒开时,每次实时 GROUP BY 就不划算了。我会在项目里再加一张预聚合表,让定时任务每天凌晨算好前一天的结果:

CREATE TABLE news_daily_stats ( stat_date DATE NOT NULL, category_name VARCHAR(32) NOT NULL, source_name VARCHAR(64) NOT NULL, total_clicks BIGINT DEFAULT 0, article_count INT DEFAULT 0, PRIMARY KEY (stat_date, category_name, source_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

写入由 Spring Boot 的 @Scheduled 定时任务触发,核心逻辑是把 news_article 按日期、分类、来源分组汇总后插入这张表:

INSERT INTO news_daily_stats (stat_date, category_name, source_name, total_clicks, article_count) SELECT publish_date, category_name, source_name, SUM(clicks), COUNT(*) FROM news_article WHERE publish_date = #{date} GROUP BY publish_date, category_name, source_name;

这个设计把查询从几百万行的实时聚合,变成了对一张窄表的等值查询,主键恰好覆盖筛选维度。做数据库课程设计时可以把这个结构演化写进设计文档,作为“数据预处理层”的亮点。要注意这张表需要定时清理过期数据,统计结果超过一年基本没有查看价值。

2.4 时区与字符集:两个隐藏比较深的坑

publish_time 建议去掉ON UPDATE CURRENT_TIMESTAMP,新闻的发布时间是业务属性,不应该随记录更新而漂移。字符集必须用 utf8mb4,新闻摘要里可能出现 emoji 或其他扩展字符,utf8mb3 会直接插入失败。

还有 springboot 配置里的时区项。MySQL 连接串上不写时区,默认跟随服务器时区,而 Spring Boot 所在的容器时区可能不一致,导致 ECharts 趋势图出现“数据偏移一天”的诡异现象。常见做法是在 jdbc 连接串上显式声明:

jdbc:mysql://localhost:3306/news_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

加上serverTimezone=Asia/Shanghai后,前后端的时间口径才算真正统一。这个问题在数据量小的时候根本暴露不出来,等趋势图日期对不上再排查,会浪费大量时间。

3. Spring Boot 聚合接口:把数据库行转成 ECharts 需要的“轴+值”

3.1 接口返回体与 VO 设计:别把 List<新闻> 直接抛给前端

见过不少平台直接把新闻列表整个返回,让前端自己去算总数和占比。数据量小看不出问题,一旦数据量上来,前端要做大量循环累计工作,页面卡顿不说,统计口径还容易和 SQL 算出来的对不上。正确做法是聚合计算全部下沉到 SQL,Spring Boot 只把计算结果包装成固定结构的 JSON 返回。

统一响应体我一般写成泛型结构,前端通过 code 判断请求是否成功,data 直接绑定图表:

@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } }

图表查询的 VO 也只要薄薄几个字段,不需要把整条新闻记录暴露出去:

@Data public class TrendVO { private String date; // 对应 ECharts x 轴 private Long value; // 对应 ECharts series 数值 } @Data public class NameValueVO { private String name; // 饼图/柱状图的类别名 private Long value; // 对应的数值 }

核心原则是“接口结果集的名字必须跟图表字段名一致”。如果后端返回{ date: '2024-06-01', value: 123 },前端用item.date就能拿到轴数据,不需要再做映射。命名不统一,前端每张图就要多一层转换逻辑,后端把 VO 定好,整条链路都会干净很多。

3.2 按天趋势、来源占比、分类占比:三条聚合 SQL 的 XML 实现

三个核心查询分别是:按日期统计点击总量(趋势折线图)、按来源统计文章数(来源占比饼图)、按分类统计点击量或文章数(分类柱状图)。Mapper XML 写法如下:

<mapper namespace="com.example.news.mapper.NewsMapper"> <select id="selectTrend" resultType="com.example.news.vo.TrendVO"> SELECT publish_date AS date, SUM(clicks) AS value FROM news_article WHERE publish_date BETWEEN #{startDate} AND #{endDate} <if test="source != null and source != ''"> AND source_name = #{source} </if> GROUP BY publish_date ORDER BY publish_date </select> <select id="countBySource" resultType="com.example.news.vo.NameValueVO"> SELECT source_name AS name, COUNT(*) AS value FROM news_article WHERE publish_date BETWEEN #{startDate} AND #{endDate} GROUP BY source_name ORDER BY value DESC </select> <select id="countByCategory" resultType="com.example.news.vo.NameValueVO"> SELECT category_name AS name, SUM(clicks) AS value FROM news_article WHERE publish_date BETWEEN #{startDate} AND #{endDate} GROUP BY category_name ORDER BY value DESC </select> </mapper>

三条 SQL 都是先按时间范围过滤,再 GROUP BY 维度字段。注意 countBySource 用的是 COUNT(*) 统计文章条数,countByCategory 用的是 SUM(clicks) 统计点击总量,两种指标的含义不要混。过滤条件里的#{startDate}#{endDate}直接用字符串,MySQL 对'2024-01-01'这样的字符串比较 DATE 类型时会隐式转换,但传入值必须是yyyy-MM-dd格式,否则算不准。

3.3 DashboardController 代码与查询参数表

Controller 只负责接收参数、做基础校验、调用 Mapper、包装 Result。完整逻辑如下:

@RestController @RequestMapping("/api/dashboard") public class DashboardController { private final NewsMapper newsMapper; public DashboardController(NewsMapper newsMapper) { this.newsMapper = newsMapper; } @GetMapping("/trend") public Result<List<TrendVO>> trend(@RequestParam String startDate, @RequestParam(required = false) String endDate, @RequestParam(required = false) String source) { if (endDate == null || endDate.isEmpty()) { endDate = LocalDate.now().toString(); } return Result.ok(newsMapper.selectTrend(startDate, endDate, source)); } @GetMapping("/source") public Result<List<NameValueVO>> source(@RequestParam String startDate, @RequestParam(required = false) String endDate) { if (endDate == null || endDate.isEmpty()) { endDate = LocalDate.now().toString(); } return Result.ok(newsMapper.countBySource(startDate, endDate)); } @GetMapping("/category") public Result<List<NameValueVO>> category(@RequestParam String startDate, @RequestParam(required = false) String endDate) { if (endDate == null || endDate.isEmpty()) { endDate = LocalDate.now().toString(); } return Result.ok(newsMapper.countByCategory(startDate, endDate)); } }

接口参数规则如下表:

参数名类型必填说明
startDateString开始日期,格式 yyyy-MM-dd
endDateString结束日期,默认当天
sourceString来源筛选,传空字符串时不生效

这里把 endDate 的默认值放在 Controller 里做兜底,比放在 SQL 里更清晰。前端传参时,把 source 留空字符串而不是 null,否则 MyBatis 的<if test="source != null and source != ''">会判断为真,导致 SQL 拼出AND source_name = null,查询结果为空。这是 MyBatis 动态 SQL 最容易踩的一个坑。

3.4 为什么选 XML Mapper 而不是 MyBatis-Plus 的 QueryWrapper

MyBatis-Plus 做单表 CRUD 确实方便,增删改查基本不用写 SQL,但聚合查询是另一回事。虽然 QueryWrapper 也支持select("publish_date, SUM(clicks)"),可一旦 SQL 变复杂、需要if条件动态拼装或者调整索引,代码里写字符串 SQL 的可读性会迅速下降。XML Mapper 的优势在于 SQL 独立维护,修改后不用重新编译 Java 代码,DBA 拿到 XML 文件就能直接调优数据库增删改查。

在一个基于 Spring Boot 的 java 毕设项目里,XML Mapper 的存在也能体现分层意识:Controller 负责参数处理,Mapper XML 负责数据访问,VO 负责接口契约。面试官问起时会更容易解释清楚“聚合逻辑放在数据库还是应用层”的问题。当然,简单的字典表查询仍然可以用 MP 的 Service,两者并不冲突。

4. ECharts 前端装配:从 fetch 数据到三类图表渲染

4.1 项目结构选择:纯 HTML + fetch 或 Vue 二选一

如果平台只服务展示场景,我会倾向于纯静态 HTML 页面,通过 fetch 请求 Spring Boot 接口,这样后端项目可以独立部署,前端也不需要 Node 构建链路。代码维护成本低,课程设计和演示都不容易出环境问题。如果后续要做复杂交互或组件复用,再迁移到 Vue 也不迟。

页面引入方式遵循 springboot 项目里最常规的做法:后端把静态页放在src/main/resources/static下,启动后直接访问同源路径,天然规避跨域问题。ECharts 通过 CDN 引入即可:

<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>

4.2 fetch 后组装 xAxis 与 series 的推荐写法

后端返回的 trend 数据是若干{ date, value }对象,ECharts 的 x 轴和 series 需要两个独立数组。直接 map 是最稳妥的写法:

const resp = await fetch('/api/dashboard/trend?startDate=2024-01-01&endDate=2024-12-31'); const result = await resp.json(); if (result.code !== 200) throw new Error('接口异常'); const dates = result.data.map(item => item.date); const values = result.data.map(item => item.value); const chart = echarts.init(document.getElementById('trendChart')); chart.setOption({ xAxis: { type: 'category', data: dates }, yAxis: { type: 'value' }, series: [{ type: 'line', data: values, smooth: true }] });

这里的关键约束是 dates 数组和 values 数组必须一一对应。ECharts 在做折线图时,如果两个数组长度不一致,图表会缺点或错位,后端 SQL 里显式ORDER BY publish_date才能保证顺序一致。使用 map 而不是 for 循环,代码意图也更明确。

4.3 折线图、饼图、柱状图的核心 option 配置

饼图比折线图简单的多,后端返回的就是{ name, value }结构,正好可以直接塞给 data:

const resp = await fetch('/api/dashboard/source?startDate=2024-01-01&endDate=2024-12-31'); const result = await resp.json(); const chart = echarts.init(document.getElementById('sourceChart')); chart.setOption({ series: [{ type: 'pie', radius: ['40%', '70%'], data: result.data.map(item => ({ name: item.name, value: item.value })) }] });

radius: ['40%', '70%']可以生成环形饼图,视觉上比实心饼图更轻盈。柱状图的配置和折线图相近,只是把type改成bar,再加上一个barMaxWidth限制柱宽,否则分类名很长时柱子会特别宽,影响可读性。

4.4 dataZoom、tooltip 与窗口 resize 的必配项

趋势图跨三个月时,六十多个点堆在同一个画布里,标签必然重叠。dateZoom 组件可以让用户自助缩放:

chart.setOption({ tooltip: { trigger: 'axis' }, dataZoom: [ { type: 'inside', start: 0, end: 100 }, { type: 'slider', start: 0, end: 100, height: 20 } ] });

type 为 inside 时鼠标滚轮缩放,slider 则显示底部的拖动条,两者配置成相同的 start 和 end 值,联动的缩放范围才一致。tooltip 的 trigger 用 axis 而不是 item,划过某一天时能看到完整上下文。

页面容器尺寸变化后图表不会自动重绘,需要在页面加载时注册 resize 监听:

window.addEventListener('resize', () => { chart.resize(); });

每一次 setOption 之前也建议先调用一次chart.clear(),否则多次请求后 ECharts 会把旧的 series 和新 series 合并,出现两条数据线叠加的怪象。

4.5 进阶:注册地图 geoJSON 并给地图加背景图

如果新闻数据带有省份字段,平台可以增加一个“热度地域分布”模块,把饼图升级成中国地图。ECharts 5 本身不内置地图数据,需要把 geoJSON 文件放在 static 目录下,前端动态注册:

fetch('/geo/china.json') .then(resp => resp.json()) .then(chinaJson => { echarts.registerMap('china', chinaJson); chart.setOption({ backgroundColor: 'url(./img/bg.jpg) no-repeat center', // geo 组件或 series 的 map 属性指向 'china' }); });

地图加背景图的做法实际是在 option 的backgroundColor里写 url 地址,地图 geo 区域浮在背景图层之上。样式上建议背景图整体偏暗,地图区域用高亮色填充,对比度更高。这是“echart 地图加背景图”的典型落地方式,新闻平台里配合省份维度做热点分析非常直观。

5. 造数脚本、链路验证与一个平台化技巧

5.1 用 Python 脚本灌入可复现的模拟新闻数据

没有真实新闻源时,我会先用 Python 生成模拟数据,把 90 天的时间跨度、来源分布、分类分布都做出来,方便趋势图和占比图有足够的数据量展示:

import random from datetime import datetime, timedelta from faker import Faker import pymysql fake = Faker('zh_CN') conn = pymysql.connect( host='localhost', user='root', password='123456', database='news_platform', charset='utf8mb4' ) cur = conn.cursor() categories = ['科技', '财经', '体育', '娱乐'] sources = ['新浪新闻', '腾讯新闻', '澎湃新闻', '36氪'] for _ in range(2000): pub = datetime.now() - timedelta(days=random.randint(0, 89), hours=random.randint(0, 23)) cur.execute( "INSERT INTO news_article(title, summary, source_name, category_name, " "publish_time, publish_date, clicks, likes) " "VALUES(%s, %s, %s, %s, %s, %s, %s, %s)", (fake.sentence()[:50], fake.text()[:200], random.choice(sources), random.choice(categories), pub, pub.date(), random.randint(0, 5000), random.randint(0, 500)) ) conn.commit() cur.close() conn.close()

脚本用 random 控制 publish_date 分布在最近 90 天,clicks 在一定范围内浮动,这样前端趋势图能看到自然的起伏而非一条直线。category 和 source 也做了随机分配,饼图和柱状图的比例会比较均匀。

5.2 三步验证 Spring Boot 到 ECharts 的全链路

数据灌完后,浏览器 Network 面板就能当作调试工具,三步验证链路是否通顺:

  • 第一步,访问/api/dashboard/trend,确认 JSON 结构里有code=200,data 数组里存在多个日期的统计值;
  • 第二步,打开页面源码,确认初始化 ECharts 时document.getElementById获取的容器不是 null,容器有明确高度;
  • 第三步,点击来源筛选下拉框,重新请求 trend 接口,看 Network 里请求 URL 是否带上了source=xxx参数,以及返回值是否随之变化。

5.3 平台化技巧:来源下拉框联动 + 折线框选右击事件

给平台加一个来源筛选下拉框,是所有图表联动的常见做法。监听 change 事件后重新 fetch trend 和 category 接口,再 setOption 更新图表,就能完成同一个维度驱动多图刷新。进阶一点,还可以在折线图上启用框选工具,让用户拉取一个时间区间看局部数据:

chart.setOption({ brush: { toolbox: ['rect'] }, toolbox: { feature: { brush: { type: ['rect'] } } } }); chart.on('brushselected', (params) => { const indexList = params.batch[0].selected[0].dataIndex; console.log('框选到的数据点:', indexList); });

再配合contextmenu事件拦截默认右键菜单,就能在框选区弹出“只看此区间”“导出图片”等自定义操作,这就是用户常说的 echart 框选右击事件。落地时注意先event.preventDefault()阻止浏览器默认菜单,再把操作绑定在当前图表的 container 上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询