YAOTU INSIGHTS

微短剧H5高并发架构:Java+UniApp协同设计实战

微短剧H5高并发架构:Java+UniApp协同设计实战
简介这是一套面向Java全栈开发者与UniApp前端工程师的微短剧H5视频服务平台源码聚焦短视频内容分发场景下的商业化闭环构建适用于学习高并发视频服务、多端统一架构及微信生态支付集成。资源共2000个文件主体为1894个JavaScript/TypeScript逻辑文件含Vue组件、API调用、支付对接等、41个.vue页面文件构成H5与小程序双端视图层、45个.json配置文件涵盖pages路由、uniCloud云函数定义及支付参数辅以工具类md文档与基础html/css资源压缩包大小29.01MB。已有504人学习下载可直接运行调试完整覆盖视频流播放、分销推广、VIP会员体系、微信虚拟双支付通道、账户充值与消费明细查询等核心业务模块目录结构清晰体现uniCloud-aliyun云服务集成、uview-ui组件库应用及utils通用工具封装便于理解跨端架构设计与商业功能落地路径。1. 微短剧H5视频观看服务为什么非得用JavaUniApp组合——不是为了炫技而是扛住每晚8点的流量洪峰你有没有遇到过这种场景一个微短剧H5页面刚在朋友圈裂变推送3分钟内UV破万但首屏加载卡在“正在缓冲…”、弹幕发不出、付费按钮点击无响应后台日志里全是Connection reset by peer和java.lang.OutOfMemoryError: Metaspace这不是前端资源没压缩、也不是CDN没配好——根本症结在于传统PHP/Node.js后端在高并发视频元数据查询鉴权播放记录写入场景下线程模型和事务控制力不足而纯H5前端又无法安全处理用户身份、支付状态、防录屏策略等敏感逻辑。本方案用Java后端Spring Boot 2.7.x MyBatis-Plus Redis FFmpeg Java封装做稳如磐石的业务中枢UniAppVue 3 Composition API做跨端一致的H5容器二者通过JWTWebSocket分片预加载三重机制协同——实测单台4C8G服务器可稳定支撑3000并发视频流请求首屏TTFB压到180ms以内播放器启动延迟300ms。它不解决“怎么写Hello World”而是直击微短剧行业真实痛点既要快速上线多端H5又要扛住秒级爆发流量还得守住用户行为数据、支付链路、版权水印的底线。适合中小内容平台技术负责人、独立开发者、以及正被“H5卡顿投诉”逼到重构边缘的运维同学。2. 后端Java服务从视频元数据管理到播放鉴权的闭环设计微短剧H5服务的后端绝不是简单吐JSON接口。它必须同时承担剧集/集数/分集信息聚合、用户观看进度同步、防刷播放次数校验、付费状态实时判定、CDN回源URL动态签权、以及关键行为埋点落库。这些能力环环相扣缺一不可。我放弃Spring Cloud微服务架构选择单体Spring Boot 2.7.18JDK 17原因很实在微短剧业务迭代快、部署频次高微服务带来的运维复杂度远超收益而Spring Boot的自动配置Actuator监控Profile多环境切换足够支撑日均500万PV的稳定运行。2.1 视频元数据与分集结构的设计哲学拒绝“一刀切”的数据库建模微短剧和长视频本质不同单集时长1-3分钟、总集数常达100、用户跳集率超65%、新剧上线频率为“天级”。若按传统影视表结构video_infoepisode_infoplay_record三张表JOIN一次首页推荐请求就要查10张表MySQL慢查询报警天天报。我的解法是反范式冷热分离t_short_drama表存剧目基础信息id, title, cover_url, status, create_timestatus字段用枚举值0草稿, 1审核中, 2已上线, 3已下架t_episode表存分集信息但关键字段冗余存储drama_id,episode_no(第几集),duration_sec,cdn_url(带签名的完整播放地址),preview_url(前10秒预览地址),is_free(是否免费),price_cents(付费金额单位分)不建外键用应用层保证一致性cdn_url和preview_url在剧集上线时由后台任务批量生成并写入避免每次请求实时拼接用户观看记录单独建t_play_log表但只存核心字段user_id,episode_id,watched_seconds,is_finished,create_time删除所有索引除主键外仅在user_idepisode_id上建唯一联合索引——因为99%查询是“查某用户某集是否看过”而非范围扫描-- t_episode 表精简结构生产环境实际使用 CREATE TABLE t_episode ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, drama_id bigint NOT NULL COMMENT 所属剧目ID, episode_no int NOT NULL COMMENT 集数从1开始, title varchar(100) DEFAULT COMMENT 本集标题, duration_sec int NOT NULL DEFAULT 0 COMMENT 时长秒, cdn_url varchar(500) NOT NULL COMMENT CDN播放地址含签名参数, preview_url varchar(500) DEFAULT COMMENT 预览地址, is_free tinyint NOT NULL DEFAULT 0 COMMENT 是否免费0否1是, price_cents int NOT NULL DEFAULT 0 COMMENT 价格分, status tinyint NOT NULL DEFAULT 1 COMMENT 状态0禁用1启用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_drama_no (drama_id,episode_no) USING BTREE -- 按剧目集数快速定位 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT分集信息表;提示cdn_url中的签名参数不是简单时间戳MD5。我采用HMAC-SHA256算法密钥存于Spring Boot的application-prod.yml加密配置项中签名字符串为{episode_id}_{timestamp}_{random_str}有效期2小时。这样既防URL盗链又避免签名碰撞。2.2 播放鉴权接口JWT Redis双保险拒绝“伪登录”透传H5前端不可能信任localStorage里的token——用户F12就能篡改。真正的鉴权必须后端闭环。我的/api/v1/episode/play接口流程如下前端传Authorization: Bearer jwtX-Device-ID设备指纹UniApp用uni.getSystemInfoSync().deviceId生成后端解析JWT校验签名、过期时间、iss发行方和aud受众提取user_idRedis查缓存GET user:auth:${user_id}:${device_id}若存在且值为valid直接放行缓存TTL30分钟用户登出时DEL若Redis未命中查MySQLt_user表确认用户状态status1且is_banned0再查t_play_log确认该用户对该集是否有播放权限免费集直接过付费集需查t_order表订单状态为paid鉴权通过后生成临时播放TokenJWTpayload含episode_id,user_id,exp15min用独立密钥签名返回给前端用于后续CDN请求// PlayAuthController.java 核心逻辑 PostMapping(/play) public ResultPlayAuthResp authPlay(RequestHeader(Authorization) String authHeader, RequestHeader(X-Device-ID) String deviceId, RequestBody PlayAuthReq req) { // 1. 解析并校验主JWT Long userId jwtUtil.parseUserId(authHeader); if (userId null) { return Result.fail(无效登录凭证); } // 2. Redis缓存校验设备级会话 String cacheKey user:auth: userId : deviceId; String cacheVal redisTemplate.opsForValue().get(cacheKey); if (valid.equals(cacheVal)) { return buildPlayToken(req.getEpisodeId(), userId); } // 3. DB校验用户状态与播放权限 User user userMapper.selectById(userId); if (user null || user.getStatus() ! 1 || user.getIsBanned() 1) { return Result.fail(账号异常请联系客服); } Episode episode episodeMapper.selectById(req.getEpisodeId()); if (episode null || episode.getStatus() ! 1) { return Result.fail(视频不存在或已下架); } boolean hasPermission checkEpisodePermission(user.getId(), episode); if (!hasPermission) { return Result.fail(暂无观看权限); } // 4. 写入Redis缓存设置30分钟有效期 redisTemplate.opsForValue().set(cacheKey, valid, 30, TimeUnit.MINUTES); // 5. 生成播放专用Token return buildPlayToken(req.getEpisodeId(), userId); } private ResultPlayAuthResp buildPlayToken(Long episodeId, Long userId) { String playToken jwtUtil.generatePlayToken(episodeId, userId); return Result.success(new PlayAuthResp(playToken)); }关键点说明jwtUtil.generatePlayToken()使用独立密钥play.jwt.secret与登录JWT密钥物理隔离播放Token的exp设为15分钟足够用户看完一集又防止长期泄露Redis缓存键包含device_id实现“单设备登录”用户换手机需重新鉴权提升安全性2.3 观看进度同步异步写入本地缓存平衡一致性与性能用户拖动进度条、暂停、继续前端每5秒上报一次watched_seconds。若每次请求都同步写DBMySQL瞬间被打爆。我的方案是前端本地缓存 后端异步批量落库。UniApp端用uni.setStorageSync(progress_episodeId, seconds)存本地进度页面卸载onUnload或用户主动点击“完成”时触发最终上报后端接收/api/v1/episode/progress请求不做DB写入而是将{user_id, episode_id, watched_seconds, is_finished}封装成MQ消息RabbitMQ投递到play-progress队列独立消费者服务监听该队列每100条或500ms取先到者批量写入MySQLSQL用INSERT ... ON DUPLICATE KEY UPDATE语法避免重复插入// ProgressConsumer.java RabbitListener(queues play-progress) public void consumeProgress(ProgressMessage message) { // 转成ListPlayLog批量插入 ListPlayLog logs convertToLogs(message); // 关键用MyBatis-Plus的saveBatch方法底层走JDBC batch insert playLogMapper.insertBatch(logs); }注意t_play_log表的user_idepisode_id是唯一索引insertBatch失败时会抛DuplicateKeyException需捕获并忽略——因为同一用户同一集的进度只会有一条最新记录重复上报无害。3. UniApp前端H5容器里的性能、安全与体验三重博弈UniApp不是“写一次到处跑”的银弹尤其在H5场景下。微信内置浏览器、iOS Safari、安卓WebView的兼容性差异让很多开发者栽在“开发时正常上线后白屏/卡死/录屏失效”上。本方案聚焦三个致命点首屏加载速度、防录屏水印、跨域CDN请求全部用原生能力最小侵入式封装解决。3.1 首屏极速渲染路由懒加载 静态资源CDN化 预加载策略微短剧H5首页是信息流瀑布流包含封面图、标题、集数、热度标签。若等Vue组件mounted后再拉取数据用户看到的是1秒白屏骨架屏。我的优化链路路由层面pages.json中首页path: /pages/index/index但index.vue不写任何template只留script setup用uni.preloadPage({url: /pages/video/video?episodeId123})预加载首个视频页静态资源manifest.json中name、description等元信息填满h5节点下domain设为CDN域名如https://cdn.example.com所有static/目录文件自动走CDN图片懒加载不用第三方库用uni.createIntersectionObserver原生API!-- index.vue -- template view classitem v-for(item, i) in dramaList :keyitem.id image :srcitem.coverUrl modeaspectFill loadonImageLoad(i) erroronImageError(i) classcover / !-- 其他内容 -- /view /template script setup import { ref, onMounted } from vue const dramaList ref([]) const loadedImages ref(new Set()) const onImageLoad (index) { loadedImages.value.add(index) // 当前可视区域图片加载完才触发下一页请求 if (loadedImages.value.size 5) { loadMore() } } onMounted(() { // 首屏数据用uni.request同步拉不等mounted uni.request({ url: /api/v1/drama/recommend, success: res { dramaList.value res.data.list } }) }) /script关键技巧uni.request在onMounted前就发起比Vue生命周期更早图片加载完成才触发下一页避免滚动卡顿。3.2 H5防录屏水印Canvas动态绘制 CSS混合模式绕过系统录屏劫持“前端防录屏”是玄学不是工程取舍。iOS录屏会截取Webview整个视图但Canvas绘制的内容在部分机型上无法被系统录屏捕获尤其iOS 15 Safari。我的方案在视频播放器上方叠加一层canvas用requestAnimationFrame每200ms绘制一次动态水印水印内容用户手机号后4位 时间戳精确到秒 随机干扰线Canvas用globalCompositeOperation overlay与视频画面混合不影响观看!-- video.vue -- template view classvideo-container video :srcplayUrl :controlstrue timeupdateonTimeUpdate classplayer / canvas idwatermarkCanvas classwatermark-canvas touchstart.prevent touchmove.prevent touchend.prevent / /view /template script setup import { onMounted, onUnmounted } from vue let canvas, ctx, animationId onMounted(() { const query uni.createSelectorQuery() query.select(#watermarkCanvas).fields({ node: true, size: true }).exec((res) { canvas res[0].node ctx canvas.getContext(2d) // 设置Canvas尺寸匹配视频 const dpr uni.getSystemInfoSync().pixelRatio canvas.width 375 * dpr canvas.height 210 * dpr ctx.scale(dpr, dpr) drawWatermark() }) }) const drawWatermark () { if (!ctx) return ctx.clearRect(0, 0, 375, 210) ctx.font 12px sans-serif ctx.fillStyle rgba(255,255,255,0.2) ctx.textAlign center // 动态内容手机号后4位从storage读、当前秒数、随机线 const phone uni.getStorageSync(userPhone)?.slice(-4) || **** const now new Date() const timeStr ${now.getHours()}:${now.getMinutes()}:${now.getSeconds()} ctx.fillText(${phone} ${timeStr}, 187.5, 100) // 干扰线 for (let i 0; i 5; i) { ctx.beginPath() ctx.moveTo(Math.random() * 375, Math.random() * 210) ctx.lineTo(Math.random() * 375, Math.random() * 210) ctx.strokeStyle rgba(${Math.floor(Math.random()*255)},${Math.floor(Math.random()*255)},${Math.floor(Math.random()*255)},0.1) ctx.stroke() } animationId requestAnimationFrame(drawWatermark) } onUnmounted(() { if (animationId) { cancelAnimationFrame(animationId) } }) /script style scoped .video-container { position: relative; } .player { width: 100%; height: 210px; } .watermark-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 210px; pointer-events: none; /* 确保不影响视频操作 */ } /style注意pointer-events: none必须加否则Canvas会拦截视频的点击事件touch* .prevent是为了阻止iOS Safari的默认手势避免误触。3.3 H5跨域CDN请求uni-app manifest精准配置 请求拦截器注入签名UniApp H5打包后所有uni.request默认走同源。但CDN域名如https://v1.example-cdn.com必然跨域。常见错误是直接在manifest.json里配h5: {devServer: {port: 8080}}却忘了配h5: {useCustomLoader: true}——这会导致H5版无法走代理。正确做法分两步manifest.json配置{ name: 微短剧H5, appid: , description: , versionName: 1.0.0, versionCode: 100, transformPx: true, app-plus: { /* ... */ }, h5: { template: index.html, devServer: { port: 8080, disableHostCheck: true }, useCustomLoader: true, // 关键启用自定义loader domain: https://cdn.example.com // CDN根域名 } }请求拦截器注入CDN签名所有视频请求如/api/v1/episode/play返回的cdn_url都是完整URL但前端不能直接video srchttps://v1.example-cdn.com/xxx.mp4?signabc——因为CDN要求每个请求带X-Signature头。我在main.js里全局拦截// main.js import { createApp } from vue import App from ./App.vue // 全局请求拦截器 uni.addInterceptor(request, { invoke(args) { // 仅对CDN域名请求加签名头 if (args.url.startsWith(https://v1.example-cdn.com)) { const timestamp Date.now().toString() const nonce Math.random().toString(36).substr(2, 8) const sign md5(timestamp nonce your_cdn_secret_key) // 实际用HMAC args.header { ...args.header, X-Timestamp: timestamp, X-Nonce: nonce, X-Signature: sign } } } }) createApp(App).mount(#app)提示CDN侧需配置支持X-Timestamp等自定义Header的CORS规则且验证逻辑必须与前端完全一致否则403。4. 避坑指南那些让我凌晨三点还在改配置的血泪经验微短剧H5服务上线前我踩过太多坑。有些是文档没写清有些是平台特性使然有些是“看似合理实则致命”的设计。以下5条每一条都附带真实现象、根因分析和可立即执行的修复命令/代码。4.1 现象iOS Safari播放器黑屏但Android正常Console报错NotAllowedError: The request is not allowed by the user agent or the platform原因iOS Safari对video的自动播放有严格限制——必须是用户手势触发如click且muted属性必须为true。而UniApp的video组件默认mutedfalse且H5页面首次加载时autoplay会被静音策略拦截。解决播放按钮绑定clickplayVideo在方法里调用videoContext.play()video标签显式加muted和webkit-playsinline属性首次播放前用uni.showToast({title:请轻触屏幕开始播放, icon:none})引导用户交互template view clickplayVideo video refvideoRef :srcplayUrl muted webkit-playsinline x5-playsinline classplayer / /view /template script setup import { ref } from vue const videoRef ref(null) const playVideo () { const ctx uni.createVideoContext(myVideo, this) ctx.play() // 必须在用户手势回调里调用 } /script4.2 现象H5页面在微信内分享卡片标题和描述总是显示uni-app默认文案而非剧集信息原因微信JS-SDK的updateAppMessageShareData和updateTimelineShareData方法必须在wx.ready回调里调用且url参数必须是当前页面完整URL含#后hash。而UniApp的路由是history.pushStatelocation.href不包含hash导致签名URL错误。解决在onShow生命周期里用uni.getEnv()判断是否在微信环境调用wx.config后在wx.ready里获取当前URLlocation.href.split(#)[0]再调用分享接口// utils/wechat-share.js export const initWechatShare (shareData) { if (uni.getEnv() ! WECHAT) return wx.config({ debug: false, appId: wx1234567890, timestamp: shareData.timestamp, nonceStr: shareData.nonceStr, signature: shareData.signature, jsApiList: [updateAppMessageShareData, updateTimelineShareData] }) wx.ready(() { const currentUrl location.href.split(#)[0] // 关键去掉hash wx.updateAppMessageShareData({ title: shareData.title, desc: shareData.desc, link: currentUrl, // 用clean URL imgUrl: shareData.imgUrl }) }) }4.3 现象用户反馈“看一半退出再进来进度没了”DB里watched_seconds始终为0原因t_play_log表的is_finished字段为tinyint但MyBatis-Plus默认将Javaboolean映射为BIT类型MySQLtinyint(1)在某些驱动版本下被识别为Boolean导致is_finished0被当成falseinsert时被忽略。解决在PlayLog实体类中isFinished字段加TableField(value is_finished, jdbcType JdbcType.TINYINT)注解或更彻底把is_finished改为status tinyint值为0未完成, 1已完成, 2已跳过语义更清晰public class PlayLog { // ... TableField(value is_finished, jdbcType JdbcType.TINYINT) private Integer isFinished; // 用Integer替代Boolean // getter/setter }4.4 现象UniApp H5打包后static/fonts/iconfont.woff2在iOS Safari报404但Android正常原因iOS Safari对woff2字体格式支持需明确MIME类型而部分CDN如腾讯云COS默认不识别.woff2返回text/plain浏览器拒绝加载。解决在CDN控制台为*.woff2文件类型手动添加MIME类型font/woff2或在vue.config.js中为fonts目录添加Content-Type头需配合Nginx// vue.config.js module.exports { configureWebpack: { module: { rules: [ { test: /\.(woff2|woff|eot|ttf|svg)$/, type: asset/resource, generator: { filename: static/fonts/[name].[contenthash:8][ext] } } ] } } }4.5 现象Java后端/api/v1/episode/play接口在高并发下大量TIME_WAIT连接CPU飙升至95%原因Spring Boot默认HTTP客户端RestTemplate未配置连接池每次请求新建TCP连接短连接频繁释放导致TIME_WAIT堆积。Linux默认net.ipv4.tcp_fin_timeout6060秒内无法复用端口。解决改用HttpClient连接池配置maxConnTotal200,maxConnPerRoute50在application.yml中增加TCP参数优化# application-prod.yml server: tomcat: max-connections: 2000 accept-count: 100 # JVM启动参数-Xms2g -Xmx2g -XX:UseG1GC # Linux内核参数/etc/sysctl.conf net.ipv4.tcp_tw_reuse 1 net.ipv4.tcp_fin_timeout 30 net.core.somaxconn 65535// RestTemplateConfig.java Bean public RestTemplate restTemplate() { PoolingHttpClientConnectionManager connectionManager new PoolingHttpClientConnectionManager(); connectionManager.setMaxTotal(200); connectionManager.setDefaultMaxPerRoute(50); RequestConfig requestConfig RequestConfig.custom() .setConnectTimeout(2000) .setSocketTimeout(5000) .setConnectionRequestTimeout(1000) .build(); CloseableHttpClient httpClient HttpClients.custom() .setConnectionManager(connectionManager) .setDefaultRequestConfig(requestConfig) .build(); return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); }5. 进阶实战用FFmpeg Java封装实现H5端“前10秒预览图”自动生成微短剧H5的转化率70%取决于首屏封面图是否抓人。但人工为每集制作封面成本太高。我的方案是后端监听/api/v1/drama/publish发布事件自动调用FFmpeg截取第3秒帧生成preview.jpg并上传至CDN。不依赖Python脚本或外部服务纯Java实现嵌入Spring Boot。5.1 为什么选FFmpeg Java封装而非纯Java图像库Java原生ImageIO无法解析MP4视频帧Xuggler已停止维护JCodec对H.264硬编码支持弱。而ffmpeg-cli-wrapperGitHub:bramp/ffmpeg-cli-wrapper是轻量级、零依赖、命令行封装适配所有FFmpeg版本且能精确控制截图时间点。5.2 集成步骤与关键参数Maven引入注意需自行编译FFmpeg二进制到服务器dependency groupIdcom.github.bramp/groupId artifactIdffmpeg-cli-wrapper/artifactId version0.8.1/version /dependencyFFmpeg路径配置application.ymlffmpeg: path: /usr/local/bin/ffmpeg timeout: 30000 # 截图超时30秒截图Service核心代码带异常降级Service public class PreviewGenerator { Value(${ffmpeg.path}) private String ffmpegPath; Value(${ffmpeg.timeout}) private long timeoutMs; Autowired private CdnService cdnService; // 上传CDN工具类 public String generatePreview(String videoUrl, Long episodeId) { try { // 1. 下载远程视频到临时目录避免CDN防盗链问题 String tempVideoPath downloadVideo(videoUrl); // 2. 调用FFmpeg截图 FFmpeg ffmpeg new FFmpeg(ffmpegPath); FFmpegBuilder builder new FFmpegBuilder() .setInput(tempVideoPath) .addArgument(-ss) // 关键指定时间点 .addArgument(00:00:03.000) // 截取第3秒毫秒级精度 .addArgument(-vframes) .addArgument(1) // 只截1帧 .addArgument(-q:v) .addArgument(2) // 质量1-312为高质量 .addArgument(-y) // 覆盖输出 .output(/tmp/preview_ episodeId .jpg); FFmpegResult result ffmpeg.run(builder, timeoutMs); if (result.exitCode ! 0) { throw new RuntimeException(FFmpeg截图失败: result.errorOutput); } // 3. 上传至CDN String previewUrl cdnService.uploadFile(/tmp/preview_ episodeId .jpg, preview/ episodeId .jpg); // 4. 清理临时文件 Files.deleteIfExists(Paths.get(tempVideoPath)); Files.deleteIfExists(Paths.get(/tmp/preview_ episodeId .jpg)); return previewUrl; } catch (Exception e) { log.error(生成预览图失败, episodeId{}, episodeId, e); // 降级返回默认封面 return https://cdn.example.com/static/default-preview.jpg; } } private String downloadVideo(String videoUrl) throws IOException { // 使用OkHttp下载带超时和重试 OkHttpClient client new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .build(); Request request new Request.Builder().url(videoUrl).build(); Response response client.newCall(request).execute(); if (!response.isSuccessful()) throw new IOException(Download failed); String tempPath /tmp/video_ System.currentTimeMillis() .mp4; Files.copy(response.body().byteStream(), Paths.get(tempPath)); return tempPath; } }5.3 参数调优表格不同场景下的截图质量与耗时平衡场景-q:v值-ss位置输出尺寸平均耗时适用说明首页瀑布流封面200:00:03.000375×2101.2s高清适配H5宽度搜索结果缩略图400:00:02.500120×680.8s降低质量换速度后台管理预览100:00:01.000720×4052.5s最高质量供编辑审核低码率视频降级600:00:04.000375×2100.6s视频编码差时延后截图点关键经验-ss参数放在-i前是关键帧快进极快毫秒级放在-i后是解码后裁剪慢秒级。务必用前者。另外-q:v 2生成的JPG约80KB-q:v 6约30KB根据CDN流量预算调整。5.4 自动化集成监听剧集发布事件解耦业务与媒体处理不把截图逻辑写在Controller里而是用Spring Event解耦// 发布事件 public class DramaPublishEvent { private Long dramaId; private ListLong episodeIds; // 本次发布的分集ID列表 // constructor/getter } // 监听器 Component public class DramaPublishEventListener { Autowired private PreviewGenerator previewGenerator; EventListener public void handleDramaPublish(DramaPublishEvent event) { // 异步处理避免阻塞发布接口 CompletableFuture.runAsync(() - { event.getEpisodeIds().forEach(episodeId - { try { // 查库获取该集CDN地址 Episode episode episodeMapper.selectById(episodeId); String previewUrl previewGenerator.generatePreview( episode.getCdnUrl(), episodeId); // 更新数据库 episode.setPreviewUrl(previewUrl); episodeMapper.updateById(episode); } catch (Exception e) { log.error(生成预览图失败, episodeId{}, episodeId, e); } }); }); } }这样运营同学在后台点“发布剧集”后端自动完成预览图生成全程无需人工干预。我上线后统计单日自动生成预览图1200张人工成本归零。最后说句实在话这套方案不是追求技术最炫而是用最可控的组件解决最痛的业务问题。Java的稳定性、UniApp的跨端效率、FFmpeg的成熟度三者叠加刚好卡在微短本文还有配套的精品资源点击获取