第一次看到“springboot小程序led广告屏app”这种项目需求时,我第一反应是:需求方通常把一整套系统理解成了一个普通App。做下来之后才发现,真正要交付的是四样东西的协同——后端用springboot提供接口和调度能力,小程序给广告主或管理人员做移动入口,LED广告屏是终端执行设备,而“app”往往不是给普通用户下载的客户端,而是跑在屏端主板上的播放器,或者给运维人员用的巡检工具。
这四条线只要理顺了,这套系统就成功了一半。怕就怕一上来就急着写页面、调接口,结果业务模型没立住,后面每加一块屏幕都是灾难。
这篇文章我就按自己做过的类似项目来拆解,把业务参与方、springboot骨架、设备接入、内容下发链路、小程序和App的职责边界,以及部署时会踩的坑全部过一遍。看完以后,即使你手里的客户需求还比较模糊,也至少知道该怎么去问、怎么去设计、怎么排优先级。
1. 四端协作的业务到底在说什么:屏、后台、小程序和App各管哪一段
LED广告屏这个行业看上去是硬件生意,实际做软件方案时,会同时撞上好几类需求:屏主想知道自己那几块屏现在是否正常播放;广告主想自己上传素材、选屏、定时长;运营方要做内容审核,要看到屏端的播放日志;维修人员又盼着能远程判断设备是大屏坏了还是网络断了。
如果你只做一个小程序给所有人用,体验一定很差。广告主不需要看到设备电压和信号强度,维修人员也不关心素材排期,强行塞进同一个页面只会互相干扰。所以在动工以前,必须先把角色拆出来。
1.1 一个看似简单的广告屏项目,背后至少有三个参与方
第一类是屏主或者物业。他们关心设备在不在线,播放是否正常,屏幕被砸了或者断电了能不能第一时间发现。这类需求对应的是设备管理和告警能力,像心跳超时、离线提醒、远程重启指令这类功能,都会在这一层出现。
第二类是广告投放方。可能是门店市场部的人,也可能是本地小商家。他们不会关心技术实现,只想知道:把图片或短视频传上去,选一块人流量大的屏,选个时间段,付完钱就完事。这类需求对应的是素材管理、屏位选择、投放时段和订单状态。
第三类是平台运营方。他们要审核广告内容是否违规,要处理极端情况下的紧急插播,还要按月给客户出播放报告。广告屏不像手机App可以随时强更,内容一旦播出去影响是实打实的,所以审核和日志回放这两件事,在技术方案里不能省。
1.2 为什么最后会拼成“springboot+小程序+App”的形态
小程序的价值是获客和登录成本低。广告主在微信里扫码就能打开,不用另外装App,素材上传、订单查询、历史投放记录都在里面完成;运营人员也可以在审批消息里点开小程序进行快速审核,这是原生App很难替代的便利性。
但小程序不适合做屏幕端的常驻播放器,因为微信生态对后台播放、视频无缝循环、开机自启这些能力限制很严格。LED屏的播放器需要长时间不熄屏、断网自动重连、定时重启,必须有一个独立App装在屏端安卓主板上才行。
springboot后端在这里其实是“总调度室”。设备列表、素材元数据、播放任务、播放日志、账号权限,这些公共数据不能散落在小程序或App各自的本地存储里。尤其当屏端数量变大之后,后端的设备管理能力和任务调度能力,直接决定了系统能不能规模化。简单来说:小程序负责入口,App负责执行,springboot负责把两边串起来。
1.3 核心数据表先设计好,后面能省掉一大半返工
没有项目正文的情况下,我只能按最常见的业务模型来设计,但如果你拿着这套表结构去和客户对齐需求,十有八九对方会说“对对对,就是这个意思”。LED广告屏管理系统的核心表大概是这些:
| 表名 | 作用 | 关键字段 | 说明 |
|---|---|---|---|
| device_info | 屏幕设备档案 | 设备编号、屏名、分辨率、经纬度、商圈、心跳时间、状态 | 一张屏对应一台安卓播放盒子 |
| material_info | 广告素材 | 文件名、素材类型、大小、时长、md5、URL、审核状态 | 图片或视频,建议都算md5避免重复存储 |
| play_task | 播放任务 | 任务编号、设备ID、素材ID、生效开始/结束时间、优先级、审核状态 | 一条任务至少要绑定一个屏和一个素材 |
| ad_order | 广告订单 | 订单号、广告主账号、任务ID、金额、支付状态 | 如果只是内部自用,可简化成投放记录 |
| play_log | 播放日志 | 任务ID、设备ID、素材ID、计划播放时间、实际上报时间、播放时长 | 所有的统计报表都从这里出 |
| account_info | 账号信息 | openid、unionid、昵称、手机号、角色类型 | 把小程序的微信身份和服务端账号做关联 |
需要提醒一点:play_task里冗余了device_id和material_id,这在数据库设计上不是最简范式,但业务上非常实用。因为查询“某块屏当前该播什么”的频率远高于其他查询,冗余字段可以少写很多关联SQL。
表之间的关系也简单:一个广告主可以下多个订单,一个订单会生成多个播放任务,一个素材可以被多个任务引用,但任务最好拆细到“某一台屏、某一个素材、某一段时间”。刚开始我只建了订单表,结果客户说同一个素材在周末和平时播放的时段不同,不得不加任务子表去解。现在回头看,任务表一步到位是最省事的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SpringBoot后端的骨架搭建与设备管理实现
springboot在这个项目里承担的工作,说出来并不神秘:提供REST接口给小程序端和App端调用,保存设备、素材、任务数据,下发控制指令,定时扫描过期任务和离线设备。但这些常规工作在广告屏场景下有几个容易被低估的点,值得单独拿出来讲。
2.1 版本选型别贪新:springboot版本太高会带来一串连锁问题
如果团队技术栈还停留在JDK8,千万别直接选最新版springboot。Spring Boot 3.x要求JDK17起步,并且把javax.servlet这一整套命名空间迁到了jakarta.servlet,如果项目里用了老版本的MyBatis、Druid或者一些第三方SDK,升级后会遇到编译不过或者某些拦截器失效的怪问题。
我个人的建议是这样:
| 场景 | 推荐方案 |
|---|---|
| 全新项目,服务器可以装高版本JDK | Spring Boot 3.x + JDK17 + MyBatis-Plus 3.5.3以上 |
| 团队现有JDK8,想先用老一套稳定跑 | Spring Boot 2.7.x + JDK8,依赖基本不会踩坑 |
| 需要集成比较老的硬件厂商SDK | 先确认SDK是否兼容Jakarta命名空间,不兼容就留在2.7 |
项目结构上,后台管理界面如果要用Vue做前后端分离,那springboot只做接口层即可。模块上我习惯按controller/service/mapper/entity四层来分,不建议按页面去分包,因为设备管理接口、素材管理接口和订单管理接口会在多个端被复用,按业务域拆分更清晰。
2.2 屏端设备接入:心跳上报是最简单也最可靠的方案
很多从App开发转过来的人,听说要给LED屏下发播放内容,第一反应是WebSocket或者MQTT长连接。长连接当然能做,但广告屏有一个特点:设备数量可能几十台上百台,网络环境未必稳定,屏端安卓主板的资源也很有限。维护一大波长连接的心跳保活成本,比想象中高得多。
我更推荐的做法是让屏端App定时向后端上报心跳,每次心跳都带上自己当前的播放状态。服务端收到心跳后,只需要返回“当前是否需要更新任务”的标记。这个方案的实时性不会像长连接那么快,但对广告屏来说完全够了——正常商业广告的播放任务变更,晚个10秒不影响任何体验;就算紧急插播,10秒内也能被屏端拉走。
心跳接口的设计可以做成这样:
java复制@RestController
@RequestMapping("/api/device")
public class DeviceHeartbeatController {
@PostMapping("/heartbeat")
public HeartbeatResult heartbeat(@RequestBody HeartbeatRequest request) {
String deviceNo = request.getDeviceNo();
// 1. 从数据库查询设备,校验设备是否存在且未禁用
DeviceInfo device = deviceService.getByDeviceNo(deviceNo);
if (device == null) {
return HeartbeatResult.error("device_not_found");
}
// 2. 更新心跳时间和当前任务版本,记录本次IP和电量
device.setLastHeartbeatTime(new Date());
device.setLocalVersion(request.getLocalVersion());
deviceService.updateById(device);
// 3. 根据本地版本号判断是否需要更新
boolean needUpdate = taskService.isDeviceVersionExpired(deviceNo, request.getLocalVersion());
return HeartbeatResult.ok(needUpdate);
}
}
刚开始可以把心跳频率设计成10秒一次,取一个“5分钟没收到心跳就算离线”的判断阈值。别在业务刚起步时就要求精确到秒级的在线率,广告屏的网络波动非常普遍,3分钟以内的心跳中断多数时候是Wi-Fi抖动而已。
心里要有个底:屏端心跳并不是只做设备存活检测。它同时也是任务更新、素材状态确认、设备告警回传的载体。我在后面的章节会把播放日志如何在心跳里顺带提交再展开一次,这是降低屏端联网频率的常用手段。
2.3 素材上传与文件存储的取舍
广告屏播放的素材无非是图片和短视频。图片一般几十KB到几MB,短视频往往是几十MB甚至上百MB。如果只是一块屏的实验项目,直接存在本地磁盘没问题;但客户如果规划了十几个屏以上,视频素材的备份和分发压力会明显变大,我更推荐使用对象存储。
本地存储的方案要处理好虚拟路径映射。springboot默认的静态资源目录不适合存上传文件,我一般单独配置一个上传目录:
yaml复制spring:
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB
upload:
dir: /data/led-screen/material
然后在Controller里接收MultipartFile,文件落盘后把访问URL返回给前端:
java复制@PostMapping("/api/material/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) {
// 用UUID做文件名,避免中文名和特殊字符带来麻烦
String originalFilename = file.getOriginalFilename();
String ext = StringUtils.getFilenameExtension(originalFilename);
String fileName = UUID.randomUUID() + "." + ext;
File dir = new File(uploadDir);
if (!dir.exists()) {
dir.mkdirs();
}
file.transferTo(new File(dir, fileName));
return Result.ok(accessPrefix + fileName);
}
这里有两个细节值得提醒。第一,视频素材上传之后要抽帧生成封面图,小程序端拉取素材列表时需要展示,不然列表全是黑框视频,体验很糟糕。第二,图片素材要校验宽高,LED屏的分辨率比例是固定的,比如常见的是1920x1080或者768x384,如果广告主上传了一张竖图,播放的时候会被拉伸变形。这种非技术性的“业务校验”最容易被忽略,但对广告主来说观感很差。
2.4 面向小程序和屏端开放的接口有哪些
接口清单不需要一开始做得很大,核心的其实是这几类:
| 模块 | 接口 | 使用方 |
|---|---|---|
| 账号 | 微信登录、手机号绑定、获取用户信息 | 小程序 |
| 设备 | 设备列表、地理位置过滤、心跳上报、远程重启 | 小程序/App |
| 素材 | 上传、删除、列表 | 小程序 |
| 投放 | 创建订单、创建任务、审核任务 | 小程序/后台 |
| 日志 | 上报播放日志、查询播放报表 | App/后台 |
接口设计有个建议:尽量让小程序端和App端走同一套接口,不要各写一套。App端上报心跳的同时,小程序端查设备状态用的还是同一张device_info表,数据天然一致。我见过有项目把设备接口写在了管理后台的Controller里,后来给App做对接的时候不得不复制一份,维护成本翻倍。提前统一成/api/device/...这种形式,后续扩展成本很低。
3. 小程序做广告主入口,App做屏端执行:移动两端的落地细节
到了移动端这里,最容易犯的错误是把小程序和App混为一谈。小程序是为了让广告主在微信生态里快速下单,App是为了让屏端硬件在一个不受微信限制的环境里稳定播放,两者的交互逻辑、生命周期、乃至UI设计思路都完全不同。
3.1 小程序登录链路:为什么“获取微信用户失败”那么常见
做小程序第一步就是登录,这里翻车率极高。原因在于微信官方策略变过很多次:早年可以直接用wx.getUserInfo拿昵称头像,后来改成wx.getUserProfile,再往后基础库调整后,有的开发者还抱着旧代码跑,结果就是热搜词里那句“小程序获取登录后的微信用户失败”。
正确做法是:登录态只依赖wx.login获取的code,后端拿code换openid和session_key,再自己生成一个业务token给小程序用。不要把手机号、头像昵称这些当登录凭证。取手机号现在需要用专门提供的button open-type="getPhoneNumber",拿到的是加密数据或code,不是直接把完整手机号放在前端返回值里。
后端拿code换openid的逻辑简单直接:
java复制String url = "https://api.weixin.qq.com/sns/jscode2session"
+ "?appid={appid}&secret={secret}&js_code={code}&grant_type=authorization_code";
Map<String, String> params = new HashMap<>();
params.put("appid", wxAppId);
params.put("secret", wxSecret);
params.put("code", code);
WxSessionResponse resp = restTemplate.getForObject(url, WxSessionResponse.class, params);
String openid = resp.getOpenid();
如果你用的不是springboot自带的RestTemplate,用OkHttp或者Hutool的HttpUtil也都可以,关键点是:appid和secret必须是同一个微信公众平台账号下的,如果小程序后台绑定的主体和你服务器代码里写的不一致,就一定会报错。
拿到openid后,我建议把它作为account_info表的唯一索引。后续扫码、授权、订单、投放记录都靠这个openid关联。小程序自己的token可以设7天或者30天过期,每次请求时后端拦截器校验一下即可,不用每次都回访微信服务器。
3.2 投放页面设计:三步式操作远比功能堆叠好用
广告投放方不是专业系统用户,别让他在小程序里面对一堆专业术语。我在实战里验证过,三步式的投放流程是转化率最高的:
第一步:选屏幕。小程序调起地图或者列表,按城市、商圈筛选出可投放的LED屏数量。点击某块屏后能看到屏的实景照片、所在位置、每天预计曝光人次。这里会用到wx.getLocation,但要注意用户拒绝授权之后要有兜底,至少放一个手工选择城市的入口。
第二步:传素材。直接从相册上传图片或视频,前端显示上传进度,后端保存素材开始审核。上传阶段就会加一层基础审核,比如图片是否清晰、视频分辨率是否太低、是否存在明显违规文案,这层过滤能帮运营减轻很多负担。
第三步:选时间段和提交订单。屏主是否开放夜间时段、周末时段是否加价,都由后台配置。小程序端把下单金额、任务排期、订单状态展示清楚就行。如果引入微信支付,就直接用小程序支付接口;如果只是公司内部考核流程,可以先做成“提交申请”再人工确认。
素材审核通过后,我们还可以用微信订阅消息通知广告主。小程序端要先引导用户订阅消息,后端在审核通过时调用订阅消息接口推送一条模板消息,效果远比短信好,还是免费的。
3.3 App不是给用户下载的,是给屏端主板和运维人员跑的
现在回到标题里的App。如果是给LED屏自身用的播放器App,它有一些反常规的要求:
- 开机自启。屏幕主板通电后播放器要自动起来,不能每次都要人点一下App图标。
- 保持常亮。播放视频期间不能休眠,需要使用WakeLock。
- 无缝循环播放。广告视频通常是短时间内循环轮播,媒体播放器要处理连续播放的间隙问题,很多低端主板解码能力弱,代码没有做预加载就会出现卡顿。
- 静默升级。屏幕上没有键盘鼠标,新版本App要让用户在后台升级,而不是让现场的人去U盘安装。
- 本地缓存优先。屏幕端不应该依赖实时视频流,应当把服务端下发的素材先下载到本地SD卡,按播放单循环播放。
这些需求决定了App的开发模式和前端的“用户友好”完全不同。真正可以借助的唯一“人机交互”场景,是运维巡检时。
所以我在实际项目里会给App做两个模式:屏端播放器模式和运维扫码模式。维修人员用手机安装运维App后,登录账号扫码识别设备,就能看到当前屏端上报的内容版本、心跳时间、音量、亮度以及最近播放日志。这种把同一App做成双模式的做法,可以少维护一个应用,很划算。
3.4 联调时的小工具和常见障碍
小程序端请求本地springboot接口最简单的方法是:用微信开发者工具开发时勾选“不校验合法域名”,然后在工具里访问本机IP。但要注意,小程序真机预览时是不会自动放行不合法域名的,如果手机预览一直报request:fail,先看看右上角胶囊里的“调试模式”有没有打开。我在联调阶段最常用的方法是让后端在nginx上先把测试环境的https映射好,小程序直接连测试域名调试,比每次强调“打开调试模式”省心得多。
另一个容易踩的坑是顶部导航栏。小程序页面如果要展示广告屏高分辨率预览图,很多团队会改成自定义导航栏,这时候就要动态获取状态栏高度和胶囊按钮位置,否则在刘海屏上会把内容顶到屏幕外。可以封装一个系统信息工具,在App启动时算好顶部占位高度。
4. 从创建排期到屏幕真正播出来:任务状态机与下发链路
这一章是整个系统的灵魂,也是很多同类项目做到一半夭折的地方。开发者觉得“我只要把素材上传了,屏幕播放不就行了”,但实际情况是:一个素材要在多个屏上播,不同屏生效时间不同,播放过程中还可能被紧急内容插播,还要统计到底播了几次。没有清晰的任务状态机和下发规则,系统一定会乱。
4.1 播放任务的状态为什么必须卡得严
建议把播放任务设计成这样的生命周期:
| 状态 | 含义 | 触发方式 |
|---|---|---|
| DRAFT | 草稿,刚创建还没提交审核 | 小程序创建订单 |
| PENDING | 已提交待审核 | 广告主提交 |
| APPROVED | 审核通过,等待播放 | 运营审核通过 |
| REJECTED | 审核驳回,可修改后重新提交 | 运营审核驳回 |
| PLAYING | 已下发给屏端且正在播放 | 屏端拉取任务 |
| EXPIRED | 已过期或手动终止 | 定时任务自动处理 |
为什么要卡审核?因为广告屏内容一旦播出去,就处于公开场合,素材审核不能省略。如果一开始没有审核状态,后面出了合规问题没有任何追溯手段。审核维度上可以后台人工审核,也可以在代码里先做接口机审拦截,但无论如何,PENDING和APPROVED这两个状态必须有。
任务从审核通过到真正播出去还有一个提前量:屏端不是实时加载流媒体,而是先下载素材再播放。如果审核完成的时间距离任务生效时间太近,可能出现屏端来不及下载素材的尴尬。我在业务上一般限制至少提前10分钟提交,后端在创建任务时会校验一次。
4.2 屏端任务的拉取机制:用版本号优化压力
前面说过,屏端会定期心跳。最简单的下发方案是:每次屏端心跳,服务端都把当前所有有效任务返回给屏端。这样做的压力在小规模没问题,但屏多了以后,每次心跳都返回整段JSON,既浪费流量又增加数据库压力。
优化办法是给“某台设备当前所有有效任务”算一个版本号。最简单的方式是把这个设备的所有任务按时间排序之后拼起来再取MD5。屏端本地也保存着自己正在播放的任务版本号,心跳时只上报一个字符串:
java复制public boolean isDeviceVersionExpired(String deviceNo, String localVersion) {
// 查询当前设备所有处于有效期的任务列表
List<PlayTask> activeTasks = playTaskService.listActiveByDevice(deviceNo);
String currentVersion = buildTaskVersion(activeTasks);
return !currentVersion.equals(localVersion);
}
如果服务端算出来的版本号跟屏端上报的不一致,就说明任务有变化,心跳响应里才让屏端重新拉取任务列表。如果一致,则只需要返回false,屏端继续放本地内容就行。
这套“基于拉取而不是推送”的方案让我很省心。服务端唯一要做的是在任务被创建、修改、过期之后,把对应设备标记为need_update;但即便不做这个标记,屏端在下一次心跳也会发现版本不一致而主动更新。流程变得非常简单健壮。如果你的任务实时性要求更高,可以在这个基础上加一个WebSocket或者接入推送通道,但拉取方案永远是保底方案。
4.3 屏端为什么要先下载素材再播放
LED广告屏App一般会维护一个本地素材目录。如果服务端返回的新任务里有视频素材,屏端先去下载视频文件到SD卡,下载成功后才把任务状态更新为可播放。这个“先下载、后播放”的机制能避免一个非常尴尬的情况:屏端正在播放时,网络卡顿导致视频画面卡死,过一会儿又自动恢复,品牌方看到后会觉得系统质量很差。
具体实现时,屏端App可以按照素材的md5值作为文件名,下载前先判断文件是否已经存在,并且比对文件大小。如果文件已存在且大小一致,说明素材没变,直接播放即可。这能节省大量网络带宽。
下载过程中还需要有断点续传。低端主板和Wi-Fi下出现下载中断非常常见,如果没有断点续传,一个几十MB的视频下载失败后又要从头开始,反复折腾,很容易把设备的存储写坏。HTTP下载在服务端需要支持Range请求头,客户端可以使用OkHttp或者Android自带的DownloadManager来做。
4.4 紧急插播与播放日志回传
运营场景里一定有“紧急插播”的需求,比如商场临时通知、消防演练、疫情防控公告。这类需求的优先级要高于普通商业广告。我在设计时会往play_task表里加一个priority字段,普通广告为1,紧急插播为10,屏端App在拉取到任务列表后,按优先级从高到低排列播放。
另外,播放日志的回传不要每播放一次素材就上报一次,这样服务端压力大,屏端也频繁消耗流量。更经济的方法是让屏端本地把播放记录先存到SQLite,然后在每次心跳时,把一批日志一起带上来,例如:
json复制{
"deviceNo": "LED0001",
"localVersion": "f8b2a1c09d4e...",
"logs": [
{
"taskId": 1001,
"materialMd5": "7b1f8e...",
"planStartTime": "2025-01-01 10:00:00",
"actualStartTime": "2025-01-01 10:00:01",
"durationSeconds": 15
}
]
}
服务端拿到日志后更新play_log表。报表统计时再按天分组,统计每个素材的实际播放次数和总播放时长。如果广告主在后台质疑某个时段没有播放,可以直接把日志里的时间戳翻出来对质,这对做商业系统非常重要。
4.5 定时任务处理过期内容和设备状态
SpringBoot本身自带的@Scheduled注解,在广告屏系统里足够用了。我一般会让一个定时任务每隔10分钟把所有PLAYING状态且当前时间超过end_time的任务改成EXPIRED,同时更新任务版本号,让屏端下一次心跳时感知到任务变化。
另一个后台定时任务是扫描设备离线状态。每次收到屏端心跳时更新device_info.last_heartbeat_time,然后一个定时任务每隔两分钟查一次,凡是最后心跳时间距现在已经超过一定阈值(比如5分钟)的设备,就标记成离线。运营者在后台看板上一眼能看出哪些屏是正常的。
在审核流程如果之后变复杂,比如涉及多人会签、分时段审批,再考虑引入Flowable这类工作流引擎也不迟。初期的广告内容审核通常一个运营人员就能搞定,用工作流反而让流程变重。
5. 联调、部署与真实项目里的坑:docker打包、域名与时间同步
跑通逻辑和真正上线之间隔着很长一段路。这一段路上没有太多高深的技术,却都是技术人员真正卡壳的地方,值得按实际排查顺序记录下来。
5.1 跨域、拦截器与登录态放行顺序
小程序开发工具有时能正常请求,但管理后台Vue网站却报跨域;或者本地请求没问题,部署到服务器后突然3000错误。这类问题的根源通常在后端跨域策略没有统一处理。
springboot里写一个全局跨域配置类是最直接的方式:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
还有一个细节:如果你写了拦截器校验登录态,一定要放行OPTIONS预检请求,否则跨域请求会在拦截器阶段被打回。可以加一个判断逻辑,让HttpMethod.OPTIONS直接返回true,或者把预检请求放在拦截器之前处理。这种问题在联调现场遇到,排查起来看似玄学,实际就是几个小配置组合起来的连锁反应。
5.2 JDK版本不一致导致的docker打包失败
很多线上环境现在都用Docker部署springboot。最常见的悲剧是在本地跑得好好的,Dockerfile里基础镜像还是openjdk:8,而项目已经用了Spring Boot 3.x编译的class文件,启动时报出“UnsupportedClassVersionError”或者“invalid source release: 17”。
这类问题的根源,是把你写代码的JDK版本和打包运行时的JDK版本割裂了。Docker构建顺序要保证编译阶段和运行阶段的JDK一致。比如本地用JDK17、springboot3,那Dockerfile可以用多阶段构建:
dockerfile复制FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package -DskipTests
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/led-screen-server.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
如果你是JDK8项目,把镜像里的17替换成对应的JDK版本即可。关键是别把maven镜像里的JDK版本和运行镜像里的JDK版本弄成不同版本。还有一个更隐蔽的问题:本地用IDE跑的时候会默认选择项目JDK,一旦你机器上装了多个JDK,maven编译用的JDK和IDE的Project SDK可能是两个版本,所以最好在pom.xml里显式声明maven.compiler.source和maven.compiler.target,让构建行为在所有机器上一致。
5.3 小程序正式版请求不到后端:域名、https证书和备案
小程序上线前,微信要求在公众平台后台配置request合法域名,而且这个域名必须是HTTPS,还要求ICP备案齐全。如果域名证书过期,或者配置的域名和后端实际访问地址不一致,前端报错多半都是“request:fail url not in domain list”。
排查这类问题有一条固定链路:
- 先看小程序后台“开发管理-开发设置-服务器域名”里配置的request合法域名,是否和后端访问地址完全一致。
- 用浏览器或curl访问一下这个HTTPS接口,确保证书没有过期,且证书完整。
- 如果没有配置域名,只在小程序开发者工具里勾选了“不校验合法域名”,那真机正式版100%会失败。
- api.weixin.qq.com这类微信域名不需要自己配置,但页面分享、支付等能力各自要求的域名可能不同,要区别对待。
nginx反代后端时至少要这样配置:
nginx复制server {
listen 443 ssl;
server_name led.example.com;
ssl_certificate /etc/nginx/cert/led.pem;
ssl_certificate_key /etc/nginx/cert/led.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
证书申请流程往往比技术开发更费时间,域名备案通常要一到两周。如果项目工期排得死,第一周就该把域名和https证书申请启动,不然最后的站点配置会拖到上线日。
5.4 屏端设备时间不准引发的播放时间混乱
还有一次我测试时发现,设备的任务总是提前或者延迟生效,一开始怀疑是时区问题,后来发现是屏端安卓主板本身的时间不准确,有些设备重启之后会回到1970年或者某一固定默认时间。如果服务端下发任务时让屏端自己判断“当前时间在不在任务时间段内”,系统就会变得非常不可靠。
正确的做法是不要信任屏端本地时间来判定任务是否生效。服务端在屏端心跳时,直接根据服务器时间筛选出当前有效任务并下发,同时告知屏端这些任务从哪个时间点开始生效,哪个时间点过期。屏端只负责按顺序播放,遇到优先级更高的插播任务就切换,不过度依赖本地时钟。
如果某些设备要支持离线播放,那就要在屏端接入NTP时间同步能力,或者由服务端把标准时间戳写在心跳响应的公共字段里,屏端每次收到心跳时校准本地时间。这个细节看起来不起眼,但它决定了很多现场问题到底能不能远程解决。
再分享一个实用经验:完成这套系统后,如果你问哪些部分最值得复用,我的建议是先做一个简单的设备仿真器,用电脑脚本或者Postman里的定时请求模拟屏端心跳,先把后端任务切换逻辑跑通,再碰真实设备。真实屏端App的开发会牵扯到开机自启、视频解码、网络切换、存储读写等一堆问题,如果后端的任务状态机和心跳逻辑还不稳定,联调时会分不清到底是谁的bug。
真到落地扩张的时候,屏的数量超过一两百台,建议再把素材分发逻辑优化一下,考虑边缘节点或者P2P分发,但这是后
