资讯详情 多端影视系统源码:一套H5适配安卓/iOS/TV与网页端
📅 2026/10/27 20:54:17
简介这套蓝彩影视V73系统面向想搭建多端影视应用的站长与个人开发者覆盖安卓、苹果、网页与TV四个客户端把数据同步、内容采集与后台运营整合到一套可部署的源码中。压缩包共304个文件、约196.3MB以127个html页面模板、48个js脚本、43个css样式表与48张png图像资源为主另有sql建表脚本、db数据文件、ttf与woff字体、apk客户端与少量说明文档基本覆盖前端页面、后端接口与打包成品的完整链路。功能侧支持对接苹果CMS一键采集、各端页面自定义、广告位投放、自定义解析接口、十级代理、易支付自收款、公告弹窗、启动图与轮播图设置、App更新推送、激活码与卡密一键生成、电视直播源配置及用户帮助文档等后台可统一管理各端展示内容。已有891人学习下载适合需要快速验证多端影视产品形态、研究H5模板改造或二次开发的技术人员参考。1. 四个端只维护一套代码这套多端影视系统的取舍在哪产品需求往往非常具体手机装安卓版苹果用户要用 iOS 版电脑浏览器要能直接打开网页版客厅里的电视盒子还得有个大字版的 TV 端。如果每个端都单独找人写一套原生四个人有一个半在重复造轮子而常见的解法就是把渲染层统一收敛到 H5原生只留一层壳。这套 V73 多端影视系统走的就是这条路子后台一套 PHP 接口前台一套 H5 页面安卓、iOS、TV 三个客户端本质都是 WebView 容器网页端直接访问同一个 H5 目录。它适合手里已经有内容源、想快速把四端同时铺出去的小团队也适合想研究「一套 H5 怎么适配遥控器和触屏两种交互」的开发者。接下来按我拆这套源码的顺序从架构、接口、播放器一路讲到部署和二次开发。2. 技术栈与目录结构一套后台怎么同时喂四个端2.1 从一次点击看整条链路用户在 TV 端按遥控器选中一部片子到画面出来中间大概经过这么几步TV 壳工程的 WebView 加载https://域名/h5/H5 的路由跳到详情页/detail/1024页面里的 axios 发起GET /api/vod/detail?id1024Nginx 把请求转给 PHP 入口public/index.php框架路由到app/api/controller/Vod.php的detail()方法查vod表和vod_play_source表组装成 JSON 返回。H5 拿到数据后渲染剧集列表用户选集时再请求/api/vod/play拿到具体某一集的分片地址或 m3u8 地址交给播放器实例。这个链路里只有一个渲染层所以四端的行为差异被压到了两端边界上一端是壳工程能不能全屏、返回键怎么处理、遥控器按键怎么映射另一端是 H5 里的适配代码UA 判断、焦点样式。中间的业务逻辑只有一份改一次四端同时生效这是这套架构最值钱的地方。常见的技术选型是 PHP 7.4 MySQL 5.7/8.0 Nginx框架层用 ThinkPHP 那一套 MVC 约定路由、模型、模板都现成H5 用 Vue 2 Vue Router axios 的经典组合打包成静态文件丢到public/h5播放器不是引一个大而全的库而是自己封一层内部按需挂 hls.js 或直接走原生 video。之所以不上 SSR、不上微前端是因为影视站的页面结构非常固定——列表、详情、播放三张页面撑起 90% 的流量任何额外复杂度都是负担。2.2 目录结构速览拿到源码先别急着跑把目录扫一遍后面找文件能省大量时间。这套系统的目录大致长这样v73/ ├── app/ │ ├── api/ # 四端共用的 JSON 接口 │ │ ├── controller/ │ │ │ ├── Vod.php # 列表/详情/播放 │ │ │ ├── Search.php # 搜索 │ │ │ └── User.php # 登录、收藏 │ │ └── middleware/ # 签名校验、跨域 │ ├── admin/ # 后台管理独立模块 │ └── common/ # 模型、工具类、公共函数 ├── public/ │ ├── index.php # 唯一入口文件 │ ├── h5/ # 打包后的 H5 静态资源 │ └── upload/ # 封面、字幕、海报 ├── client/ │ ├── android/ # Android 壳工程 │ ├── ios/ # iOS 壳工程 │ └── tv/ # Android TV 壳工程leanback ├── config/ │ ├── database.php │ └── app.php ├── runtime/ # 缓存、日志、编译产物 └── nginx.conf.exampleapp/api和app/admin分开是关键设计——后台只有管理员能用接口要面对公网两者的鉴权、限流、缓存策略完全不同混在一起迟早出事。client目录放壳工程源码方便和 H5 版本对齐比如 H5 升级了播放器要新增一个 JS Bridge 方法壳工程得同步发版。2.3 把环境跑起来的最小步骤新手最容易卡在环境上。我一般在服务器上先确认三件事PHP 版本和扩展、MySQL 版本、Nginx 伪静态。PHP 至少 7.4必须装的扩展是pdo_mysql、mbstring、curl、json、fileinfo前两个不装直接白屏。# 1. 确认版本PHP 低于 7.2 直接换别硬撑 php -v mysql --version nginx -v # 2. 确认扩展齐全 php -m | grep -E pdo_mysql|mbstring|curl|fileinfo|json # 3. 建库、导入 SQL mysql -uroot -p -e CREATE DATABASE v73 DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; mysql -uroot -p v73 install/v73.sql # 4. 给运行时目录写权限这一步漏了会一直报错 chown -R www:www runtime public/upload chmod -R 755 runtime public/upload数据库连接在config/database.php里改四个参数hostname、database、username、password。改完访问后台/admin默认账号密码一般在安装文档里进去第一件事就是改掉。Nginx 的伪静态是第二个高频翻车点。入口只有public/index.php所有不在磁盘上的路径都要重写过去server { listen 80; server_name your-domain.com; root /www/wwwroot/v73/public; index index.php index.html; # 静态资源缓存封面图走这里能省不少带宽 location ~* \.(jpg|jpeg|png|webp|css|js|woff2)$ { expires 7d; access_log off; } # 关键路径不存在就交给入口文件 location / { try_files $uri $uri/ /index.php$is_args$args; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } # hls 分片目录别让 PHP 去处理 location /hls/ { add_header Cache-Control no-cache; } }try_files那一行是核心写成rewrite也能跑但try_files对目录索引更友好。配置完nginx -t测一下重启后先访问/api/vod/list只要返回 JSON 就说明后端通了再去管前端。3. 后端接口与数据模型片库、线路、播放地址怎么落库3.1 三张表撑起整个片库影视系统的数据模型比想象中简单核心就三张影片主表、播放线路表、分类表。收藏、历史这些是附属表不影响主流程。CREATE TABLE vod ( vod_id int unsigned NOT NULL AUTO_INCREMENT, vod_name varchar(120) NOT NULL DEFAULT COMMENT 片名, vod_sub varchar(120) NOT NULL DEFAULT COMMENT 别名/副标题, vod_type_id smallint unsigned NOT NULL DEFAULT 0 COMMENT 分类ID, vod_year smallint NOT NULL DEFAULT 0 COMMENT 年份筛选用, vod_area varchar(40) NOT NULL DEFAULT COMMENT 地区, vod_remarks varchar(60) NOT NULL DEFAULT COMMENT 更新至第xx集列表页直接显示, vod_score decimal(3,1) NOT NULL DEFAULT 0.0, vod_pic varchar(255) NOT NULL DEFAULT COMMENT 竖版封面, vod_blurb varchar(255) NOT NULL DEFAULT COMMENT 一句话简介详情页头部用, vod_content text COMMENT 完整简介, vod_status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, vod_hits int unsigned NOT NULL DEFAULT 0 COMMENT 播放量排序用, vod_time int unsigned NOT NULL DEFAULT 0 COMMENT 更新时间戳, PRIMARY KEY (vod_id), KEY idx_type_status (vod_type_id,vod_status), KEY idx_time (vod_time), KEY idx_hits (vod_hits) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个字段值得单独说。vod_remarks存的是「更新至 24 集」这种给人看的字符串而不是让前端拿总集数去拼因为影视内容的更新状态五花八门——「全 40 集」「HD 国语」「抢先版」靠字段拼不出来直接存字符串最省事。vod_blurb和vod_content拆开也有讲究前者是列表 hover 和详情页头部用的一句话限长 255后者是完整剧情可能上千字列表接口绝对不查后者否则一次请求拖几百 KB 文本。播放线路单独一张表用 JSON 存剧集数组CREATE TABLE vod_play_source ( source_id int unsigned NOT NULL AUTO_INCREMENT, vod_id int unsigned NOT NULL DEFAULT 0, source_name varchar(40) NOT NULL DEFAULT COMMENT 线路名如 线路一, source_sort smallint NOT NULL DEFAULT 0 COMMENT 线路排序越小越靠前, source_episodes mediumtext COMMENT JSON数组[{n:第01集,u:https://...m3u8}], source_status tinyint NOT NULL DEFAULT 1, PRIMARY KEY (source_id), KEY idx_vod (vod_id,source_status,source_sort) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么不把每一集拆成一行一部 40 集的剧、3 条线路就是 120 行详情页一次要全查出来行数多了 join 成本反而高而且选集永远是「按线路整体读」没有单独查某一集的需求。用 JSON 存的好处是一次查询拿到全部剧集代价是不能按集做 SQL 筛选——但这场景根本不需要。提示source_episodes用 mediumtext 不要用 text长剧集加上备用地址很容易超过 64KB。3.2 列表接口分页、缓存、字段裁剪三件事列表接口是四端调用最频繁的手机端一屏 6 个、TV 端一屏 12 个、网页端瀑布流可能一次要 20 个。接口设计成统一返回 20 条前端自己截取避免三套分页逻辑。?php // app/api/controller/Vod.php namespace app\api\controller; use think\Db; use think\Cache; class Vod { // GET /api/vod/list?type_id1page1sorttime public function lists() { $typeId input(type_id/d, 0); // 0 表示全部 $page max(1, input(page/d, 1)); $sort input(sort/s, time); // time | hits | score $limit 20; // 缓存键必须带上排序维度否则换排序会读到旧数据 $cacheKey vod:list:{$typeId}:{$sort}:{$page}; $data Cache::get($cacheKey); if ($data) { return json([code 200, msg ok, data $data, cache 1]); } $where [vod_status 1]; if ($typeId 0) { $where[vod_type_id] $typeId; } // 只查列表需要的字段content 这种大字段绝不能进来 $rows Db::name(vod) -where($where) -field(vod_id,vod_name,vod_remarks,vod_pic,vod_score,vod_year,vod_hits) -order($this-buildOrder($sort)) -page($page, $limit) -select(); $data [ list $this-formatList($rows), page $page, has_more count($rows) $limit, ]; Cache::set($cacheKey, $data, 300); // 列表 5 分钟够用了 return json([code 200, msg ok, data $data]); } private function buildOrder($sort) { // 白名单映射避免 order 参数被注入 $map [ time vod_time desc, vod_id desc, hits vod_hits desc, vod_id desc, score vod_score desc, vod_id desc, ]; return $map[$sort] ?? $map[time]; } private function formatList($rows) { foreach ($rows as $r) { $r[vod_pic] $r[vod_pic] ?: /static/img/default.jpg; $r[vod_hits_text] $r[vod_hits] 10000 ? round($r[vod_hits] / 10000, 1) . 万 : (string)$r[vod_hits]; unset($r[vod_hits]); // 数字不直接给前端省得两边各算一次 } return $rows; } }这段代码有三个点值得抄。第一order参数做了白名单映射直接拼字符串是经典的注入面order by位置不能用参数绑定只能白名单。第二缓存键包含了sort我见过太多项目缓存键只写到type:page结果加了排序功能后数据全乱。第三列表接口主动把vod_hits转成「1.2万」这种展示文本再返回四端不用各写一遍格式化逻辑——这类「展示层逻辑下沉到接口」在四端同源的架构里收益特别高。缓存用文件驱动还是 Redis看量。日活几千用文件缓存完全够runtime/cache下会生成一堆哈希文件量上来再切 Redis改config/cache.php的 driver 就行代码不用动。3.3 详情与播放接口线路切换怎么不重新加载页面详情接口返回影片信息加所有线路的剧集列表播放接口只返回当前这一集的地址。分开的理由是详情页需要在没有播之前就把选集渲染出来让用户能直接点第 8 集。?php // 详情GET /api/vod/detail?id1024 public function detail() { $id input(id/d, 0); if ($id 0) { return json([code 400, msg 参数错误]); } $cacheKey vod:detail:{$id}; $data Cache::get($cacheKey); if (!$data) { $vod Db::name(vod) -where([vod_id $id, vod_status 1]) -find(); if (!$vod) { return json([code 404, msg 影片不存在或已下架]); } $sources Db::name(vod_play_source) -where([vod_id $id, source_status 1]) -order(source_sort asc, source_id asc) -select(); foreach ($sources as $s) { $eps json_decode($s[source_episodes], true) ?: []; // 只给集名和序号地址留到播放接口再下发 $s[episodes] array_map(function ($e, $i) { return [i $i, n $e[n]]; }, $eps, array_keys($eps)); unset($s[source_episodes]); } $data [vod $vod, sources $sources]; Cache::set($cacheKey, $data, 600); } // 播放量异步加别让写操作拖慢读接口 Db::name(vod)-where(vod_id, $id)-setInc(vod_hits); return json([code 200, msg ok, data $data]); }注意详情接口故意不下发真实播放地址。第 4 章会讲播放地址是四端里最容易被抓包的东西把地址延后到播放接口再给配合签名和有效期能挡掉相当一部分直连盗用。这是一层很薄的防护但对小站来说成本极低。?php // 播放GET /api/vod/play?id1024sid2i7 public function play() { $id input(id/d, 0); $sid input(sid/d, 0); // 线路 ID $idx input(i/d, 0); // 集序号 $source Db::name(vod_play_source) -where([source_id $sid, vod_id $id, source_status 1]) -find(); if (!$source) { return json([code 404, msg 线路不存在]); } $eps json_decode($source[source_episodes], true) ?: []; if (!isset($eps[$idx])) { return json([code 404, msg 剧集不存在]); } // 下游 CDN 地址加时效签名防止被直接引用 $url $this-signUrl($eps[$idx][u], 7200); return json([ code 200, msg ok, data [ url $url, name $eps[$idx][n], next isset($eps[$idx 1]) ? [i $idx 1] : null, prev $idx 0 ? [i $idx - 1] : null, ], ]); } private function signUrl($url, $ttl) { $expire time() $ttl; $secret config(app.play_secret); // 独立密钥别和登录密钥共用 $sign md5($secret . $expire . parse_url($url, PHP_URL_PATH)); $sep strpos($url, ?) false ? ? : ; return $url . $sep . expire{$expire}sign{$sign}; }这里返回next和prev给前端是因为 TV 端的「下一集」按钮和自动连播都依赖它让前端自己算容易在最后一集越界。签名部分如果 CDN 侧没配校验sign就是纯摆设上线前一定要跟运维确认这一点不然加了半天等于没加。4. H5 前端与播放器一个播放页要处理的三件事4.1 播放器初始化先判断支不支持再决定用哪条路移动端播放最容易翻车的地方是把 hls.js 无脑用在所有浏览器上。iOS Safari 原生支持 m3u8你再挂一层 hls.js两边抢同一个 video 元素表现就是黑屏、只有声音、或者进度条拉到一半卡死。// src/utils/player.js import Hls from hls.js export default class Player { constructor(videoEl) { this.video videoEl this.hls null } load(url) { this.destroy() // Safari / iOS 走原生安卓和桌面走 hls.js const canNative this.video.canPlayType(application/vnd.apple.mpegurl) if (canNative) { this.video.src url this.video.play().catch(() { // 自动播放被拦交给用户点一下别抛错弹窗 }) return native } if (Hls.isSupported()) { this.hls new Hls({ maxBufferLength: 30, // 缓冲 30 秒太长占内存 liveSyncDurationCount: 3, // 直播场景的同步阈值 manifestLoadingTimeOut: 10000, }) this.hls.loadSource(url) this.hls.attachMedia(this.video) this.hls.on(Hls.Events.ERROR, (e, data) { // 分片 404 常见于换线路后旧地址直接重载一次 if (data.fatal data.type Hls.ErrorTypes.NETWORK_ERROR) { this.hls.startLoad() } }) return hls } return unsupported } destroy() { if (this.hls) { this.hls.destroy() this.hls null } this.video.removeAttribute(src) this.video.load() } }canPlayType返回字符串maybe或不是布尔值判断时直接当条件用就行但别写成 true。destroy()必须在换集和页面销毁时都调用单页应用里路由切走不销毁 hls 实例内存会一直涨安卓低端机播两三集就卡。maxBufferLength设 30 秒是我调过比较舒服的值。设太大比如 120低端机内存吃满崩溃设太小10弱网下频繁卡顿。TV 端可以调大到 60因为盒子内存宽裕且网络通常走有线。4.2 一套交互两种输入触屏手势和遥控器焦点四端同源最大的技术债在这里——手机是手指滑动TV 是遥控器上下左右加确认键网页是鼠标。这三套输入不能靠「都绑 click」糊过去尤其是 TV 端没有焦点概念的话用户根本不知道当前选中了哪部片子。常见做法是加一层输入适配层TV 端加载remote.js其他端加载touch.js两者都往同一个事件总线抛「上/下/左/右/确认/返回」六个语义事件页面只监听语义事件。// src/adapter/remote.js —— TV 端按键映射 const KEY_MAP { 37: left, 38: up, 39: right, 40: down, 13: ok, 8: back, 179: back, // 部分盒子的返回键 415: play, 19: pause, } export function bindRemote(bus) { document.addEventListener(keydown, (e) { const action KEY_MAP[e.keyCode] if (!action) return e.preventDefault() // 关键拦住默认滚动否则焦点和页面一起动 bus.emit(remote, action) }) }e.preventDefault()这行不能省。TV 盒子的 WebView 默认会把方向键当成页面滚动你不拦就会出现「焦点往下走一格页面同时滚半屏」的怪异体验用户会以为遥控器坏了。焦点样式用 CSS 类名切换别用:focus伪类。原因很实在TV 端经常需要在不可聚焦的卡片上画一个「虚拟焦点框」div本身加不了tabindex时会失效而且:focus在各家盒子 WebView 上的表现并不一致玄学问题一堆。/* 焦点框统一走 class逻辑层只负责加/删这个类 */ .card.focused { transform: scale(1.06); box-shadow: 0 0 0 3px #2f6bff; transition: transform .15s ease; z-index: 2; }4.3 首屏性能慢 1 秒跳出率肉眼可见影视站的用户耐心极低尤其电视端遥控器按下去两秒没反应就会连按然后崩。首屏能压的地方有几个封面图、字体、接口并发。封面图是最容易忽略的。后台传的是原始 jpg一张 300KB一屏 12 张就是 3.6MB。常见做法是上传时同步生成_thumb后缀的缩略图宽 300 的 webp列表页直接引用缩略图详情页才用原图。这个改动不需要前端配合一个上传钩子就够收益却是立竿见影的。接口层面首页要的「分类 推荐 排行榜」是三个接口串行发就是三次 RTT。改成并发或者干脆在后台加一个/api/home/index的聚合接口在服务端拼好一次性返回首屏能省 200-400ms。// 并发拉取别用 await 一个个串 const [cats, recommends, ranks] await Promise.all([ api.getCats(), api.getList({ type_id: 0, sort: score, page: 1 }), api.getList({ type_id: 0, sort: hits, page: 1 }), ])4.4 三个壳工程的出包要点Android 壳工程本质就是一个 Activity 加 WebView配置项就那么几个但漏一个就出问题// client/android/app/src/main/java/.../MainActivity.java WebSettings s webView.getSettings(); s.setJavaScriptEnabled(true); // 必须 s.setDomStorageEnabled(true); // localStorage 不开启观看历史全丢 s.setMediaPlaybackRequiresUserGesture(true); // 允许用户手势后自动播 s.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE); // 只加载远程 https别用 file:// 加载本地 html跨域能折腾死人 webView.loadUrl(https://your-domain.com/h5/); // 返回键先给 H5 处理处理不了再退出 Override public void onBackPressed() { webView.evaluateJavascript(window.__onBack window.__onBack(), value - { if (!true.equals(value)) super.onBackPressed(); }); }setDomStorageEnabled这行是血泪经验。不开启H5 里的localStorage会静默失败表现是「观看历史永远空」「音量设置每次重启都重置」而且不报错很难查。iOS 壳工程用 WKWebView要注意 ATS 只允许 httpsInfo.plist里别为了图省事全局放开 http。TV 端壳工程在AndroidManifest.xml里要声明 leanback launcher否则装到电视上桌面找不到图标category android:nameandroid.intent.category.LEANBACK_LAUNCHER / !-- 同时保留普通 launcher很多国产盒子只认这个 -- category android:nameandroid.intent.category.LAUNCHER /5. 避坑与排查上线前踩过的五个坑5.1 播放黑屏与线路切换失效现象安卓端播放正常iOS 和部分浏览器点开只有声音没画面或者播放到一半停住切线路也没反应。原因两种情况混在一起。一是上一章说的iOS 上同时存在原生 src 和 hls.js 两个控制器互相抢 media 元素二是切线路时只改了 m3u8 地址没销毁旧实例旧的 hls 还在往 video 上 attach。解决先在真机上抓canPlayType的返回值确认走的是哪条分支再检查切换线路的代码有没有调用destroy()。我一般会在load()开头强制this.destroy()宁可多销毁一次也不要留两个实例。另外hls.js的版本要和打包配置对齐混用两个版本的构建产物也会出现诡异的分片加载失败。5.2 部署后只有首页能打开接口全 404现象本地php think run一切正常上传到服务器后首页能开一点分类就 404接口地址返回 Nginx 的 404 页面。原因伪静态没配或者root指到了项目根目录而不是public目录。/api/vod/list这种路径在磁盘上不存在没有try_files兜底就直接 404 了。另一种情况是PATH_INFO模式下 Nginx 没传PATH_INFO变量。解决确认root结尾是/public加上try_files $uri $uri/ /index.php$is_args$args;。如果框架走的是 pathinfo 路由再加一段fastcgi_split_path_info的配置。改完nginx -t nginx -s reload然后用curl -I https://域名/api/vod/list看返回码不要用浏览器看浏览器缓存会让你误判。5.3 后台改了片名前台还是旧的现象后台编辑保存成功刷新前台列表纹丝不动得等好几分钟才更新。原因列表和详情都加了 5-10 分钟缓存但保存时没有主动清。缓存键是vod:list:{type}:{sort}:{page}一条数据可能命中几十个键逐个删不现实。解决给缓存打标签保存时按标签整体清。文件缓存驱动不支持标签的话退一步的做法是在vod表加个version缓存键里带上全站版本号任何写操作就让版本号加一旧键自然失效靠过期时间回收。这个方案糙但极其可靠我一般先上这个量大了再换 Redis 的 tag。5.4 TV 端装上了但桌面找不到图标现象APK 用 U 盘装到盒子上提示安装成功但在应用列表里翻遍了也没有。原因只声明了LEANBACK_LAUNCHER国产盒子大多不是标准 Android TV只认普通LAUNCHER或者 APK 只打了arm64-v8a的 so盒子的 CPU 是 32 位。解决两个 category 都写上abiFilters至少保留armeabi-v7a和arm64-v8a两种。另外检查minSdkVersion很多老盒子还停在 API 21 附近设成 24 就装不上。5.5 接口偶发 502日志里看不到原因现象白天正常晚上高峰期零星 502PHP 慢日志里没有记录。原因多半是 PHP-FPM 进程池打满。列表接口没走缓存命中时每次都查库加 json_encode 大数组单个请求耗时上来进程被占满Nginx 等不到就返回 502。解决先看php-fpm的pm.max_children是不是设得太保守默认 5 肯定不够再给列表接口加缓存。另外json_encode大数组确实费 CPU列表接口只查必要字段这一点在高峰期就是救命稻草。改完压一轮用ab -n 2000 -c 50跑一下看 P99 有没有压到 200ms 以内。6. 二次开发加一个「观看历史」模块该动哪些文件想验证自己真的读懂这套结构最好的练手需求是加观看历史。它跨了后端、H5、壳工程三层但每一层的改动都很小改完能立刻在四个端看到效果。后端加一张表和一个接口。表只要三个字段user_id、vod_id、progress秒、updated_at联合唯一键(user_id, vod_id)用户重看同一部片子时更新而不是插入。接口设计成POST /api/user/history上报、GET /api/user/history拉取上报时顺手把progress写进去拉取时按updated_at倒序取 20 条。权限走现有中间件不用新写鉴权。H5 层在播放器里挂一个timeupdate监听每 15 秒上报一次切集和页面卸载时各补报一次。这里有个坑timeupdate触发频率很高每秒 4 次左右直接在里面发请求会把接口打爆必须做节流。上报失败不用重试历史数据丢一条无所谓别为了它加重试队列。// src/utils/history.js export function bindHistory(video, vodId, episode) { let last 0 video.addEventListener(timeupdate, () { const now Math.floor(video.currentTime) if (now - last 15) return // 15 秒一次够用了 last now api.post(/user/history, { vod_id: vodId, episode: episode, progress: now, }).catch(() {}) // 静默失败不打扰用户 }) }壳工程这边基本不用改除非你想做「从历史记录直接续播」——那就需要 H5 通过 JS Bridge 告诉原生当前进度原生在冷启动时把进度回传给 H5。这个桥接方法加在已有的 Bridge 对象上就行别新开一套通信机制。改动验证的方法我一般分三步先在浏览器里改localStorage里伪造一个用户 token看接口通不通再用安卓真机播 30 秒杀掉进程重进看历史有没有落库最后在 TV 端用遥控器操作一遍确认焦点在历史列表里不会乱跳。三步都过这个模块才算真的能用光在浏览器里点通不算数。从那以后我每次改这套多端系统都强制自己先在 TV 端走一遍完整流程——遥控器是最挑剔的输入设备它过了手机和网页基本不会出问题。希望这些拆解能帮你少走点弯路。本文还有配套的精品资源点击获取