交通信息发布平台这名字听着唬人,但说白了就一件事:把分散的交通数据汇聚起来,经过审核和加工,再按不同渠道发布出去。真正动手做的时候才会发现,难点根本不在“发布”这个动作本身,而是数据从哪来、时效怎么保证、审核怎么不卡流程、大屏展示怎么让人一眼看懂。我这次用 SpringBoot 做后端服务、Vue 做前端管理端和可视化大屏,前后端分离把整套平台落地上线了。这篇文章会把从需求拆解、技术选型、核心模块设计到部署联调踩坑的完整过程都整理出来,给正在准备做同类信息发布系统的人当个参考。
整篇内容主要围绕这么几个问题展开:数据接入层怎么抽象才不用每个数据源都改一遍业务代码;审核与发布的状态流怎么设计才不容易出乱子;WebSocket 实时推送和 Vue 大屏之间的联动怎么做才不卡;最后还有 SpringBoot 版本过高、Vue 构建资源映射这类最常见却最烦人的部署问题,我也一起写了。适合刚从单体转向前后端分离的同学,也适合正在做交通、政务、园区类信息发布系统的人翻一翻,里面的一部分方案直接拿来改改就能用。
1. 需求拆解:智能交通信息发布平台到底要解决什么问题
1.1 三个核心问题,决定整个系统的边界
我接手这个项目时,需求文档里写得很宏伟,“智能”“全渠道”“实时”之类的词堆了一堆。但我给团队定的第一条规矩是:先把用户真正关心的三件事列清楚。
第一,信息从哪来。路况拥堵、交通事故、施工占道、公交临时调整、气象预警,数据来源五花八门。有的数据能拿到标准接口,有的只能靠值班人员手工录入,还有的是固定格式的文件每天定时同步。如果每一个来源都单独写一套处理逻辑,后期维护就是灾难。
第二,信息怎么被信任。自动接进来的权威数据可以直接发布,但人工录入的内容绝对要过审核。交通信息的敏感性不用多说,一条“某路段临时封闭”的消息如果发错了,直接影响大量车辆绕行。所以审核环节不是要不要的问题,而是怎么设计才能既合规又不拖累效率。
第三,信息发到哪里。大屏 LED、管理后台列表、公共查询端、移动端,每个渠道对格式、时效和权限的要求都不一样。管理端发的东西不能直接推到外部大屏,外部查询端也不能看到内部审核备注。
把这三个问题捋顺,系统边界就自然而然出来了:需要一个统一的数据接入层,负责不同来源数据的接收、解析和标准化;需要一个业务处理层,负责清洗、去重、审核、优先级判断和发布状态管理;还需要一个展示层,把管理端和对外发布端分开处理。
1.2 为什么选 SpringBoot + Vue 而不是别的组合
前后端分离不是潮流,是这套业务场景下的实际需要。后端处理数据接入、审核流转、定时任务、WebSocket 推送,压力集中在接口稳定性和并发上;前端要做复杂的管理界面、可视化大屏、地图交互,这两块如果耦合在一个单体项目里,开发和上线节奏会被互相拖死。
SpringBoot 这边,生态成熟是最大优势。交通信息对接要用的 HTTP 客户端、定时任务、权限框架、消息中间件,Spring 全家桶都有现成方案,不需要自己造轮子。而且团队里招人容易,SpringBoot 的普及率太高了,后面维护不愁没人接手。
Vue 这边,我选择它而不是 React,核心原因是管理端和大屏这种中后台场景里,Vue 的模板语法更直观,组件化开发效率高,而且和 ECharts、腾讯地图这些可视化库的配合非常顺。另一个私心是 Vue 的中文文档和社区资料质量很高,团队新人上手成本低。这个项目涉及大量表单、列表、状态标签,Vue 的响应式数据绑定写起来比手动操作 DOM 舒服太多。
版本选择上我当时也犹豫过。SpringBoot 2.7 和 3.x 之间,我最后选了 2.7,因为 3.x 刚出来时很多第三方 starter 还在适配期,尤其是我要用的一些地图和消息组件,踩坑成本太高。这个决定在后来集成过程中真的帮了大忙,具体原因放到第5章细讲。
2. 后端核心模块:数据接入、审核流转与定时任务怎么落地
2.1 数据接入层的抽象设计,避免每个数据源都改业务代码
数据接入是这类平台最容易写烂的地方。如果直接在每个 Controller 里写死调用某个外部接口,前三个月项目能跑,半年后接入第六个数据源时就会崩溃。
我用的方式是抽象一个数据源适配器接口,把“取数据”和“处理数据”彻底分离。
public interface TrafficDataSource { // 数据源类型,用于标识和配置 DataSourceType type(); // 拉取原始数据,不同实现返回不同格式的原始数据 List<RawTrafficData> fetch(); // 把原始数据转换为平台统一的消息模型 List<TrafficMessage> convert(List<RawTrafficData> rawList); }第三方路况接口、人工录入、Excel 定时导入,都是这个接口的一个实现。人工录入比较特殊,它不是主动拉取,而是等待用户在页面上提交,所以我单独做了一个ManualDataSourceImpl,convert 方法直接返回页面提交的数据。
@Component public class ManualDataSourceImpl implements TrafficDataSource { @Override public DataSourceType type() { return DataSourceType.MANUAL; } @Override public List<RawTrafficData> fetch() { return Collections.emptyList(); } @Override public List<TrafficMessage> convert(List<RawTrafficData> rawList) { // 人工提交的数据已经在Controller层封装为TrafficMessage return rawList.stream().map(raw -> (TrafficMessage) raw).collect(Collectors.toList()); } }这样做最大的好处是业务层完全不用关心数据到底从哪来,只需要面向TrafficDataSource接口编程。新增一个数据源时,写一个新的实现类,注册到 Spring 容器里,然后在配置中心加上对应的开关就行,已有的消息处理、审核、发布逻辑一行都不用动。
还有一点值得提,数据源接入时的幂等设计。外部接口偶尔会重复推送,或者定时任务重跑,如果不做幂等,同一条交通消息会被插入两次。我在消息表里加了source_msg_id唯一索引,同一个来源的消息 ID 重复时就跳过,这个字段在多个数据源场景下基本上是必需品。
2.2 审核流程的状态机设计:不该穿越的状态一律不让穿越
交通信息一旦发错,影响面很大,所以审核流程必须严格。但严格不等于代码写死多个 if-else,那样状态多了以后根本维护不了。我用的是一套简单的状态机。
消息的状态流转我定义成下面这条链路:
草稿(DRAFT) -> 待审核(PENDING) -> 已发布(PUBLISHED) -> 已下线(OFFLINE) | | +-> 已驳回(REJECTED) +-> 已撤回(WITHDRAWN)代码里我把状态转换写成了一张表,用 Map 或者枚举校验器来管理,不允许的状态迁移直接抛异常。
public enum MessageStatus { DRAFT(0), PENDING(1), PUBLISHED(2), REJECTED(3), WITHDRAWN(4), OFFLINE(5); private static final Map<MessageStatus, Set<MessageStatus>> ALLOWED_TRANSITIONS = Map.of( DRAFT, Set.of(PENDING), PENDING, Set.of(PUBLISHED, REJECTED), PUBLISHED, Set.of(OFFLINE, WITHDRAWN), OFFLINE, Set.of(PUBLISHED) ); public boolean canTransitionTo(MessageStatus target) { return ALLOWED_TRANSITIONS.getOrDefault(this, Set.of()).contains(target); } }实际业务中还有个隐藏需求:审核通过后,消息是立即发布还是定时发布。我在状态流转里加了个publishTime字段,如果审核时选了定时发布,状态转到 PUBLISHED 但实际推送动作由定时任务触发。这样审核人只需要关心内容对不对,不用掐着时间点手动点发布。
权限设计上,审核接口必须是管理员角色才能调用,普通编辑只能提交和撤回自己的草稿。Spring Security 的@PreAuthorize注解可以搞定,但这个项目里我连 RBAC 都没铺开,只在用户表里加了一个role字段,用自定义权限注解控制接口访问,够用且不复杂。
2.3 定时聚合与发布任务的两种实现方式
这里要说清楚,Spring 的@Scheduled和 Quartz 到底怎么选。我项目中两个都用了,场景完全不同。
数据聚合类任务,比如每 5 分钟拉一次路况接口、每 15 分钟同步一次公交调整信息,这种任务无状态、单机执行就行,直接用@Scheduled最简单。
@Component @Slf4j public class TrafficDataSyncTask { private final List<TrafficDataSource> dataSources; public TrafficDataSyncTask(List<TrafficDataSource> dataSources) { this.dataSources = dataSources; } @Scheduled(cron = "0 */5 * * * ?") public void syncExternalTrafficData() { dataSources.stream() .filter(source -> !source.type().equals(DataSourceType.MANUAL)) .forEach(source -> { try { List<TrafficMessage> messages = source.convert(source.fetch()); trafficMessageService.saveAndPublish(messages); } catch (Exception e) { log.error("数据源[{}]同步失败", source.type(), e); } }); } }但定时发布任务不一样,它要求可靠:到点了必须发布,不能因为服务重启就丢任务。@Scheduled是内存态的,重启后内存里的任务状态全丢了,所以定时发布我用了 Quartz 的JobDetail加CronTrigger,Job 状态持久化到数据库。这样即使服务宕机重启,未执行的发布任务也会继续触发。
Quartz 集成时有个容易忽略的点:默认的RAMJobStore也是内存态,必须配JobStoreTX或JobStoreCMT把 job 数据持久化到数据库,才能真正实现“重启不丢任务”。
2.4 大文件上传下载的处理
交通信息发布时经常要附带图片、短视频作为现场佐证,比如事故现场的短视频、施工路段的照片。这些文件动辄几十上百 MB,直接一次性上传,网络稍微波动就失败。
我实现了一套前端分片 + 后端合并的方案。前端把文件按固定大小(比如 5MB)切成多片,逐片上传,后端每收到一片就往磁盘写一片,全部传完后调一次合并接口。
// 前端分片上传逻辑 const CHUNK_SIZE = 5 * 1024 * 1024; function createChunks(file) { const chunks = []; let start = 0; while (start < file.size) { chunks.push(file.slice(start, start + CHUNK_SIZE)); start += CHUNK_SIZE; } return chunks; } async function uploadWithChunks(file, uploadId) { const chunks = createChunks(file); for (let i = 0; i < chunks.length; i++) { const formData = new FormData(); formData.append('file', chunks[i]); formData.append('uploadId', uploadId); formData.append('chunkIndex', i); await axios.post('/api/upload/chunk', formData); } await axios.post('/api/upload/merge', { uploadId, fileName: file.name }); }这里要提醒一个自己实现时的细节:合并时要用 stream 追加写,不要用 FileOutputStream 直接一个字节一个字节写,也不要把所有 chunk 一次性读进内存,文件大时直接 OOM。
3. 数据库设计与实时推送链路的关键细节
3.1 核心表结构设计:为什么状态字段要单独设计
这节先说表结构。平台核心表其实就四张:消息表、用户表、设备/渠道表、发布记录表。
消息表是整张设计的核心。我列几个关键字段:
CREATE TABLE traffic_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, msg_type VARCHAR(32) NOT NULL COMMENT '消息类型:ROAD_SITUATION/ACCIDENT/CONSTRUCTION/BUS/WEATHER', title VARCHAR(200) NOT NULL, content TEXT, level TINYINT DEFAULT 3 COMMENT '严重级别:1-紧急 2-较高 3-普通', status TINYINT DEFAULT 0 COMMENT '状态:0-草稿 1-待审核 2-已发布 3-已驳回 4-已撤回 5-已下线', source VARCHAR(64) COMMENT '数据来源:MANUAL/API_XXX/SYNC_FILE', source_msg_id VARCHAR(128) COMMENT '来源消息ID,用于幂等去重', publish_time DATETIME COMMENT '计划发布时间', expire_time DATETIME COMMENT '过期时间,过期自动下线', create_by BIGINT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_by BIGINT, audit_time DATETIME, channel_ids VARCHAR(255) COMMENT '发布渠道ID集合,逗号分隔', KEY idx_status_publish (status, publish_time), UNIQUE KEY uk_source_msg (source, source_msg_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交通信息发布消息表';source_msg_id这个唯一索引我前面提过,是幂等去重的关键。idx_status_publish索引则是为了定时任务扫描“已发布且到达发布时间”的消息时不会全表扫描。
发布记录表也很重要,它记录了每条消息发到了哪个渠道、推送结果成功还是失败、失败原因是什么。这个表的价值在事后追溯,一旦某条消息发错了,可以快速定位是什么时候、由哪个渠道推送出去的。
CREATE TABLE publish_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, message_id BIGINT NOT NULL, channel_id BIGINT NOT NULL, push_status TINYINT DEFAULT 0 COMMENT '0-待推送 1-成功 2-失败', fail_reason VARCHAR(500), retry_count TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_message (message_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;3.2 WebSocket 实时推送:为什么不用轮询
消息发布之后,前端大屏和管理端列表都要立刻看到最新状态。用 HTTP 轮询每 5 秒刷一次也不是不行,但体验上明显有延迟,而且大屏同时开好几块,轮询请求量会放大很多倍。
WebSocket 是这个场景的自然选择。连接建立后,服务端可以主动把新消息、状态变更、设备离线通知推到前端,延迟控制在毫秒级。
但 WebSocket 有一个常见的坑:连接鉴权。WebSocket 的握手阶段是一次普通的 HTTP 请求,可以拿到 URL 参数和 header,但握手成功后,后续消息帧里不会再携带 token。所以鉴权必须在握手拦截器里做,鉴权通过后把用户信息放进attributes,后续处理器从session.getAttributes()里取。
@Component public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { if (request instanceof ServletServerHttpRequest) { ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request; String token = servletRequest.getServletRequest().getParameter("token"); Long userId = tokenService.parseUserId(token); if (userId != null) { attributes.put("userId", userId); return true; } } response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } }消息推送的时机我做了分类。普通消息更新走广播通道,就是sendToAll,所有前端实例都能收到。但像审核驳回这种操作,只需要通知提交者本人,这时候用用户级通道,只推给指定 userId 对应的 WebSocket 会话。
前端在大屏上收到的消息格式我统一成这样的 JSON:
{ "type": "MESSAGE_PUBLISHED", "data": { "messageId": 1001, "title": "学府路西段发生交通事故,请绕行", "level": 2, "publishTime": "2025-01-12 09:30:00" } }前端根据type字段分发处理逻辑,新消息就插入列表并高亮,状态变更就更新对应行的状态标签。
WebSocket 还有一个不能忽略的点是心跳保活。Nginx 默认 60 秒会断开空闲连接,而 WebSocket 本身没有内置心跳机制。我实现里用了 Spring 的WebSocketHandler配合Scheduler,每 30 秒给前端发一次 ping 消息,前端收到后回一个 pong,双方都通过这个机制判断连接是否还活跃。
3.3 推送失败的重试与补偿
大屏部署在公共场所,网络环境不一定稳定,消息推送失败是常态。我的做法是推送动作不直接依赖 WebSocket,而是先写publish_record表,状态为“待推送”,再触发 WebSocket 推送。如果推送失败,记录更新为“失败”,由定时任务每 30 秒扫描一次失败记录,自动重试最多 3 次。
这里有个设计上的小心机:把“业务成功”和“推送成功”分开。业务上的发布成功,是指消息状态变成 PUBLISHED;推送成功是指前端确实收到了。两者不能混在一起,否则一旦推送端出问题,消息状态也会卡住,审核流程就被拖死了。
4. Vue 前端:管理后台与可视化大屏的完整实现
4.1 Vue 工程搭建、路由设计与请求封装
前端部分我用的是 Vue 3 + Vite。选 Vite 的原因很实在:启动速度快,开发时热更新几乎秒开,比起旧版 Webpack 那套慢吞吞的编译体验,Vite 对开发效率的提升是质的。
项目结构我按功能模块划分,没有用默认的 views/components 平铺结构。原因很简单:这种中后台项目模块边界非常清晰,按模块分包可以让多个开发同时改代码时冲突降到最低。
src/ ├── api/ # 接口请求定义 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── layout/ # 整体布局 ├── router/ # 路由配置 ├── stores/ # 状态管理 └── views/ ├── dashboard/ # 大屏展示 ├── message/ # 消息管理 ├── audit/ # 审核管理 ├── device/ # 设备管理 ├── statistics/ # 统计报表 └── system/ # 用户/角色管理路由的配置里有一个大屏项目都遇到过的问题:路由模式。管理后台我用的是 history 模式,地址栏干净、刷新页面不会丢路由,但 history 模式要求服务器把不存在的路径都回退到 index.html,否则一刷新就 404。这部分 Nginx 配置我放在第5章讲。
请求封装这块属于是前端工程化的基本功,但我还是要重点强调一下,因为团队的接口规范往往在这里体现。
import axios from 'axios' const service = axios.create({ baseURL: '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => { // 后端统一返回 { code, data, message } const res = response.data if (res.code === 0) { return res.data } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { if (error.response?.status === 401) { router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )baseURL用/api而不是写死的 IP 和端口,这是为了联调和部署的灵活性。开发时可以靠 Vite 的代理把/api转发到后端,生产环境靠 Nginx 转发,前端代码完全不用改。
4.2 管理后台:消息管理、审核与设备管理
管理后台的页面功能相对标准,但有几个交互细节让我反复改了很久。
消息列表页:默认只显示“非下线”的消息,通过 Tab 切换查看不同状态。列表要支持按类型、级别、来源筛选。这里我踩了一个分页的坑:用 Vue 的computed做前端筛选分页,数据量小的时候没问题,消息量到了几千条以后,筛选和分页都开始卡。后来改成数据全部走后端分页,前端只负责传参数,流畅度提升明显。
审核页面:审核人打开一条待审核消息,能看到消息全文、来源、建议发布的渠道,以及发布时间的预填值。审核操作有两个按钮:通过和驳回。驳回时必须要写驳回原因,这是硬校验,防止审核人随手点击,让提交人一头雾水。
设备管理页:这个页面管理的是大屏 LED、信息发布终端等硬件设备。每个设备维护一个在线状态,设备通过定时上报心跳来保活。页面上的状态指示灯是一个 Vue 的动态 class,根据设备状态显示不同颜色的圆点。设备离线超过 5 分钟会在列表顶部显示告警条,这个数据是 WebSocket 推过来的。
4.3 实时大屏与地图可视化
大屏是整个平台最受瞩目的部分,也是技术上最繁琐的部分。我把大屏拆成三个区域:顶部标题栏、中间地图区、左右两侧的数据面板。
地图这块我用了腾讯地图的 JavaScript API。选它的原因一个是文档中文比较好读,另一个是交通类数据在腾讯地图上有不少现成的图层处理方案。地图上需要展示的内容包括:正在发生的交通事故点位、施工路段区域、公交临时调整的线路高亮。
import { Map } from '@tarjim/qq-map-sdk' const map = new Map('map-container', { center: [39.9042, 116.4074], zoom: 11 }) // 新增事故标记点 function addAccidentMarker(message) { const marker = new map.Marker({ position: message.location, title: message.title, content: `<div class="accident-marker">${message.title}</div>` }) }这里要提醒一点,地图实例和 Vue 组件的生命周期必须处理好。Vue 组件销毁时,地图实例要手动destroy(),否则页面切换后地图实例还挂在内存里,大屏长时间运行内存会一路涨上去,最终浏览器崩溃。
图表部分用的是 ECharts。大屏上有车流量趋势折线图、消息类型占比饼图、渠道发布成功率柱状图。ECharts 的更新我尽量用setOption而不是重新实例化图表,这样动画过渡自然,性能也好得多。
WebSocket 和大屏的联动是整个前端的核心逻辑。大屏组件在onMounted中建立 WebSocket 连接,收到消息后判断消息类型,如果是新的交通消息,将点位加进地图,并把消息摘要插入侧边滚动列表;如果是状态变更,更新对应点位的状态颜色。
const ws = new WebSocket(`ws://${location.host}/ws/traffic?token=${token}`) ws.onmessage = (event) => { const payload = JSON.parse(event.data) if (payload.type === 'MESSAGE_PUBLISHED') { addAccidentMarker(payload.data) messageList.unshift(payload.data) } }大屏上还有一个滚动消息列表的效果,我一开始用的是 CSS 动画,但循环滚动时总是有闪烁和卡顿。尝试几种方案后,最后用了插件,让列表平滑循环滚动,消息多的时候也没有卡顿感。
4.4 Vue 播放实时视频流 m3u8
交通大屏除了数据展示,还要接入现场路口的监控视频。视频流是 HLS 格式的 m3u8 地址,Vue 播放 HLS 需要引入hls.js库。
import Hls from 'hls.js' function playM3u8(videoElement, url) { if (Hls.isSupported()) { const hls = new Hls() hls.loadSource(url) hls.attachMedia(videoElement) hls.on(Hls.Events.MANIFEST_PARSED, () => { videoElement.play() }) } else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { // iOS Safari 原生支持 HLS videoElement.src = url } }这里有个实际部署的坑:如果视频流和前端不在同一个域名下,跨域问题经常导致视频无法播放。解决方式是让后端在返回视频列表时直接给一个后端代理地址,播放时走代理,避免跨域。另外一个容易忽略的点是视频组件销毁时要hls.destroy(),否则多路视频反复切换,内存占用会快速上升。
5. 部署集成中的版本坑与调优经验
5.1 Spring Boot 版本过高引起的兼容问题
这个是整个项目里花费时间最多的坑,写出来希望你能避开。
项目后期我有一次尝试把 SpringBoot 从 2.7 升到 3.2,结果整个工程直接编译不过。原因很典型:SpringBoot 3.x 从 Java EE 规范切到了 Jakarta EE,所有javax.*包都要改成jakarta.*。
// SpringBoot 2.x import javax.persistence.Entity; import javax.validation.constraints.NotBlank; // SpringBoot 3.x import jakarta.persistence.Entity; import jakarta.validation.constraints.NotBlank;如果项目里只是一两个 import 还好说,但我项目里有几十个实体类和一堆参数校验注解,全量替换不是改个包名这么简单。更麻烦的是,有几个第三方 starter 当时还没适配 3.x,比如我用的旧版资源映射配置和一些工具包,一升级就冲突。
后来我试了一些兼容性改造方案,但评估下来还是要动的东西太多,决定退回 2.7。这个经历给我最大的教训是:对于业务系统,稳定优先于追新。SpringBoot 3.x 再香,如果团队没有准备好应对组件生态的迁移成本,升级就是不划算的。
如果你确实要新开项目,建议直接查一下自己用的 starter 是否已经支持 SpringBoot 3.x。如果支持,用 3.x 没问题;如果不确定,用 2.7 或 2.6 会更稳妥。项目稳定运行比什么都重要,框架版本高不高真没那么大的差距。
5.2 SpringBoot 打包进 Docker Desktop 时踩的坑
项目部署时我准备做成 Docker 镜像,本地用 Docker Desktop 调试。这里有一个很隐蔽的坑:SpringBoot 的 Dockerfile 基础镜像版本必须和宿主机架构匹配。我的开发机是 Apple Silicon 芯片,直接拉默认的openjdk:8-jdk-alpine镜像,启动时直接报错说明架构不兼容。后来改用带arm64标签的基础镜像,问题才解决。
FROM eclipse-temurin:8-jre WORKDIR /app COPY target/traffic-platform.jar /app/app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]另外提一句,SpringBoot 项目打成 jar 包后,如果用 Dockerfile 默认的 CMD 命令启动,时区会用 UTC,定时任务的执行时间会差 8 个小时。这是个非常隐蔽的坑,我在测试环境跑定时任务时发现时间总是不对,后来在 Dockerfile 里显式加了一行:
ENV TZ=Asia/Shanghai加上之后定时任务时间就正常了。这个问题一旦上线才发现,排查成本会很高。
5.3 Vue 前端安装、构建配置与 history 路由刷新 404
前端构建环境这块,最常见的坑是版本不匹配。Vite 项目对 Node 版本有要求,我用的是 Vite 5,要求 Node 18+。如果本机 Node 版本太老,执行npm install和npm run build会直接报错。
安装依赖的时候还有另一个问题:npm install很慢或者卡死。这种情况在国内环境下很常见,解决方式是切换 npm 镜像源。我现在的做法是直接在项目根目录加.npmrc文件,把 registry 固定下来,这样团队每个人拉下来的依赖版本一致,也能避免镜像不一致导致的锁文件冲突。
registry=https://registry.npmmirror.com/前端构建还有一个必须注意的地方,history 路由模式下的 404 问题。我用 Vue Router 的 history 模式,直接访问http://服务器IP/message这个地址时,Nginx 找不到这个路径会返回 404。解决办法是在 Nginx 里配置 fallback:
location / { root /opt/traffic-web/dist; index index.html; try_files $uri $uri/ /index.html; }这样所有前端路由路径都会回退到index.html,由 Vue Router 自己解析路径,刷新就不会 404 了。
5.4 SpringBoot 资源映射与前后端联调
资源映射的问题出现在上传的视频、图片文件怎么被前端访问上。SpringBoot 默认只处理静态资源目录下的文件,上传到本地磁盘的文件路径默认是访问不到的。
我的实现是在后端加一个资源映射配置,把磁盘上的上传目录映射到/upload/**这个虚拟路径:
@Configuration public class ResourceConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:/opt/traffic-upload/"); } }这样前端可以直接通过http://后端地址/upload/xxx.jpg访问上传的文件。但生产环境一般不会直接用后端地址访问资源,而是通过 Nginx 配置一个/upload/代理到磁盘目录,这样静态资源和接口分离,后端只承担业务逻辑,性能更好:
location /upload/ { alias /opt/traffic-upload/; }还有一个我在联调阶段踩到的坑:前端开发服务器跑在 5173 端口,后端接口在 8080 端口,直接请求必然跨域。开发时用 Vite 的代理配置解决:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true }, '/ws': { target: 'ws://localhost:8080', ws: true } } }注意/ws的代理要加ws: true,否则 WebSocket 握手会被代理截断,连接一直建立不起来。这个配置我找了好久才发现是缺了ws: true这个参数,相当隐蔽。
5.5 性能优化:大屏页面加载慢和列表卡顿
大屏页面的性能问题几乎是一定会遇到的。我总结下来主要有三个瓶颈。
第一个是地图初始化加载慢。腾讯地图的 JavaScript API 脚本比较大,首次加载会明显等待。我做了两件事:把地图脚本改成异步加载,不用index.html里同步引入;大屏页用路由懒加载,用户进入大屏页时才加载地图组件。
第二个是 ECharts 图表数据渲染卡顿。大屏上有多个图表,每个图表的定时刷新如果都直接改整个 option,ECharts 会频繁重绘,性能很差。我的做法是,图表更新时分类型处理:折线图只更新series.data,饼图只更新series[0].data,不要每次都重置完整的 option。这个优化在大屏上效果非常明显,CPU 占用率降低了一半以上。
第三个是消息列表频繁更新导致 DOM 重排。大屏上滚动消息列表每几秒就插入一条新消息,如果不加限制,DOM 节点会无限增加。我限制了列表最大长度,超过 100 条就自动截断,保证 DOM 数量可控。
部署上线后,我用压测工具对后端的/api/message/list和/api/message/publish两个核心接口做了一轮压测。在 500 并发下,接口响应时间从最初的 800ms 优化到 200ms 以内。主要优化点有两个:一个是列表查询加上了必要的索引,另一个是消息内容字段在列表接口中只返回摘要,全文走详情接口,减少大数据量传输。这两点对于信息发布类系统的优化非常通用。
这套系统从需求梳理到上线,前后大概用了三个月。回过头看,最有价值的不是某个单独的技术亮点,而是所有模块之间的配合方式:数据接入层让新数据源变得可插拔,审核状态机让业务安全可控,WebSocket 加上 Vue 的响应式更新让前端能实时感知后端变化,这两者是我觉得最值得复用的设计。
如果你也要做类似的信息发布平台,我的个人建议是,一开始不要急着堆功能,先把数据接入的抽象和审核状态流设计做好。这两个地基一旦打稳,后面加渠道、加设备、加大屏都是顺理成章的事。反过来,如果地基没打好,数据源一多代码就乱套,审核流程一长就出状态乱跳的问题,后期返工成本会非常高。
最后再分享一个实际操作中的小技巧:大屏页面开发时一定开着浏览器的 Performance 面板,专门盯内存曲线。大屏页面通常要长时间挂机运行,内存泄漏在这个场景下特别致命。我开发期间就抓出来两个泄漏点,一个是地图实例没销毁,一个是 ECharts 的监听器没卸载。这类问题在普通页面看不出影响,但在大屏场景上,跑一天之后浏览器卡死的概率几乎是百分之百。提前处理掉,后续运维会轻松很多。