ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

IPTV双端源码实战:m3u8源解析、ExoPlayer起播与鉴权缓存

IPTV双端源码实战:m3u8源解析、ExoPlayer起播与鉴权缓存 简介这份《新骆驼IPTV完美版后端源码 APP源码》面向安卓手机与电视盒子端的电视直播应用开发者提供前后端完整代码参考便于理解IPTV系统结构并进行二次开发或功能定制。压缩包体积约21.72MB文件总数登记为0、类型明细暂未提供但从标题与描述可确认主体为后端服务源码与安卓APP源码两部分涉及频道与播放列表接口、数据库存储、认证授权、流媒体分发以及客户端界面、播放器集成、网络请求和遥控器适配等模块。已有约4600人学习下载具备一定参考热度。读者可据此梳理IPTV源接入到客户端播放的调用链路学习RESTful API设计、用户权限控制、视频流协议与播放缓冲处理后端侧可参考服务器请求处理、频道数据组织与流媒体服务配置前端侧可关注播放器缓冲策略、JSON数据解析与遥控器焦点控制适合具备安卓或后端基础、希望搭建电视直播平台的中级开发者查阅。1. 一套 IPTV 双端源码省掉的是什么从源管理到客户端起播的最小闭环某园区要做一面内部电视墙采购按点位报价算下来不如自己搭一套。这类需求最后大多落到同一个东西上一套 IPTV 双端源码后端 APP。后端负责把 iptv 源也就是 m3u / m3u8 播放列表收进来做分类、去重、排序对外只暴露登录、频道列表、取播放地址、节目单四五个接口APP 端拿列表渲染成频道格子点进去直接起播。这套包真正省时间的地方不在于播放器写得多漂亮而在于「源 → 频道 → 客户端」这条链路的骨架已经搭好了数据库表、接口字段、鉴权头都是通的你只需要往里填自己的源和自己想要的界面。适合想自建可控直播/点播系统、想研究双端接口对接、手里有现成播放源要管的开发者不适合只想双击装个 App 就能看的人那类需求用现成播放器就够了。2. 拆包先认三层后端目录、APP 工程与数据库核心表压缩包解开之后别急着找启动脚本。这类「后端 APP」的双端包结构通常分成三个互相独立的部分认清楚哪层归哪层后面改代码才不至于在后端目录里找 Android 的布局文件。我拿到一个新包的习惯是先把顶层目录列一遍再用文件大小排个序看看有没有明显的「大头」——通常那个大头就是后台管理前端或者播放器 SDK。2.1 后端目录入口文件、应用层与扩展层后端绝大多数是 PHP 系ThinkPHP 5.x 或 Laravel 风格或者 Spring Boot看根目录有没有composer.json、pom.xml、artisan、think这几个文件就能判断。PHP 包的典型目录长这样你的包里名字可能略有出入但分层逻辑一致backend/ ├── public/ # Web 根目录index.php 是唯一入口 ├── application/ # 应用层api 模块、admin 模块分开 │ ├── api/ # 给 APP 用的接口 │ │ ├── controller/ # 控制器Channel.php、User.php、Epg.php │ │ ├── service/ # 业务层SourceService、SignService │ │ └── middleware/ # AuthToken.php 鉴权中间件 │ └── admin/ # 后台管理加频道、导源、看设备 ├── config/ # 数据库、缓存、签名密钥 ├── extend/ # 第三方扩展播放器 SDK、m3u 解析库常在这 ├── runtime/ # 缓存和日志注意要可写 └── .env # 数据库四件套跑之前必改这里有个容易看漏的点application/api和application/admin是两套控制器共用同一批 model。很多新手改接口改到admin目录下的方法去了结果 APP 行为纹丝不动查半天以为是缓存问题。判断方法很简单看路由文件里api/v1这个分组绑的是哪个模块。另外extend/里常常塞了去广告、源探测之类的脚本二次开发前先扫一眼不用的删掉能少一堆依赖冲突。2.2 APP 工程Android 模块划分与播放器依赖APP 端以 Android 原生 Java/Kotlin 工程居多也有 uni-app 或 Flutter 的壳。原生工程看settings.gradle里 include 了几个模块常见的拆法是app主壳player播放器封装common网络、工具。播放器依赖是重点看build.gradle里引的是 ExoPlayer、ijkplayer 还是系统 MediaPlayerdependencies { // 版本以你包里的 gradle 为准别照抄先看本地缓存有没有 implementation com.google.android.exoplayer:exoplayer-core:2.19.1 implementation com.google.android.exoplayer:exoplayer-hls:2.19.1 // 播 m3u8 必须有 implementation com.google.android.exoplayer:exoplayer-ui:2.19.1 implementation com.github.bumptech.glide:glide:4.16.0 implementation com.squareup.okhttp3:okhttp:4.11.0 }exoplayer-core只负责容器和音视频轨m3u8 的切片解析在exoplayer-hls这个独立扩展里。见过不少人只引 core起播转圈到超时日志里报UnrecognizedInputFormatException其实就是少这一行。Glide 用来加载频道台标很多包为了省体积自己写了个图片缓存效果一般换成 Glide 是常见改造点。OkHttp 一般被包在common模块的网络封装里留意它有没有做统一加 headertoken 通常在那注入。2.3 数据库三张表频道、源、用户设备数据库结构决定你后面改功能顺不顺手。这类项目核心表其实就三张频道表、源表、用户设备表。源表存着 m3u 解析后的原始地址频道表通过source_id引用源这样一个频道可以挂主备两条源CREATE TABLE iptv_channel ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(64) NOT NULL COMMENT 频道名对应 m3u 里的 tvg-name, group_id int unsigned NOT NULL DEFAULT 0 COMMENT 分类 id, logo varchar(255) NOT NULL DEFAULT , source_id int unsigned NOT NULL DEFAULT 0 COMMENT 主用源, backup_source_id int unsigned NOT NULL DEFAULT 0 COMMENT 备用源主源超时切这条, sort int NOT NULL DEFAULT 0, status tinyint NOT NULL DEFAULT 1 COMMENT 1 上架 0 下架, PRIMARY KEY (id), KEY idx_group (group_id, status, sort) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_group这个联合索引是列表接口的性能命门。APP 首页一次请求就是「按分类取上架频道按 sort 排」没这个索引几百个频道加几千条源首次加载能拖到两三秒。backup_source_id这个字段很多粗糙的包干脆没有只有一条源源一挂频道就废。如果你的包里缺这个字段加字段 在取地址逻辑里做降级是性价比最高的一个改造具体写法在第 3 章。2.4 环境清单与启动命令跑之前先对齐版本环境这件事别嫌啰嗦PHP 7.4 的包丢到 PHP 8.2 上跑each()、动态属性这些老写法会直接白屏。先看composer.json里的require段和README或部署说明把 PHP 版本、MySQL 版本对上。常见组合是 PHP 7.4 / MySQL 5.7 / Redis 5Redis 不是必须的但列表缓存基本都靠它没有的话要把缓存配置改成 file。# 1. 装依赖不要带 dev能少一半体积 cd backend composer install --no-dev # 2. 复制配置并修改数据库连接 cp .env.example .env # 编辑器打开 .env改 DB_HOST / DB_DATABASE / DB_USERNAME / DB_PASSWORD # 3. 导入表结构和初始数据 mysql -u root -p your_db database/install.sql # 4. 起服务0.0.0.0 是为了让手机能连进来 php think run -H 0.0.0.0 -p 8000Laravel 风格的包把第 3、4 步换成php artisan migrate和php artisan serve --host0.0.0.0 --port8000。-H 0.0.0.0这参数特别关键默认只监听127.0.0.1模拟器能通、真机一定连不上这个坑后面还会提。runtime/或storage/目录记得给写权限不然日志写不进去报错信息全丢排查的时候就是黑匣子。3. 后端链路一次频道列表请求从鉴权到签名的完整走法后端的活儿说到底就一句话把数据库里的频道变成客户端能直接setMediaItem的地址。听着简单中间有三道关——请求得先被放行源得被解析成结构化数据地址还得带上过期签名防止被人抓包外传。这三段串起来就是整个后端最值得看的部分。3.1 路由与 token 鉴权请求怎么被放行先把入口收敛。APP 端不需要知道你有多少控制器它只需要四五个固定路径。路由文件通常长这样?php // route/route.php Route::group(api/v1, function () { Route::post(login, api/User/login); // 设备号/账号换 token Route::get (channel, api/Channel/lists); // 分类 频道列表 Route::get (channel/:id/play, api/Channel/play); // 取单个频道的可播地址 Route::get (epg, api/Epg/today); // 当日节目单 })-middleware(AuthToken); // 整组挂鉴权别一个个加把中间件挂在group上而不是单个路由上是为了防止以后加接口忘了加鉴权——这种事我见过不止一次新加一个channel/hot接口忘了挂被人扫到直接白嫖带宽。鉴权本身通常不用查库token 里自带 uid、过期时间、签名验签就行?php namespace app\api\middleware; class AuthToken { public function handle($request, \Closure $next) { $token $request-header(X-Token, ); if ($token ) { return json([code 401, msg missing token], 401); } $parts explode(., base64_decode($token)); if (count($parts) ! 3) { return json([code 401, msg bad token], 401); } [$uid, $expire, $sign] $parts; // 用和登录时相同的 key 重算签名再做定长比较 $expect hash_hmac(sha256, $uid . . . $expire, config(app.token_key)); if ((int)$expire time() || !hash_equals($expect, $sign)) { return json([code 401, msg token expired], 401); } $request-uid (int)$uid; // 往后传给控制器 return $next($request); } }几个参数说明一下。X-Token是自定义头有的包用Authorization: Bearer改的时候两边要一起改后端改了 APP 没改就是全量 401。$expire是绝对时间戳APP 端要处理 401 后重新登录别把 token 写死进代码。hash_equals而不是是为了防时序攻击虽然这种项目一般没人盯但写上不费事。签名的 key 放config/app.php里别硬编码在中间件换 key 的时候你会感谢自己。3.2 iptv 源聚合把 m3u 解析成客户端直接能播的结构这是整个后端最有含金量的部分。iptv 源一般是 m3u 文本格式是#EXTINF描述行加一行地址交替出现。解析器要能扛住各种不规范写法描述行里可能带tvg-name、group-title、tvg-logo也可能什么都没有只有一个逗号后的名字?php namespace app\api\service; class SourceService { /** * 解析 m3u 文本返回 [频道名 播放地址] * param string $raw m3u 文件全文 */ public function parseM3u(string $raw): array { $out []; $name ; foreach (preg_split(/\r\n|\r|\n/, $raw) as $line) { $line trim($line); if ($line ) { continue; } if (stripos($line, #EXTINF) 0) { // 优先取 tvg-name没有就取最后一个逗号之后的内容 if (preg_match(/tvg-name([^]*)/i, $line, $m)) { $name trim($m[1]); } elseif (($pos strrpos($line, ,)) ! false) { $name trim(substr($line, $pos 1)); } continue; } if ($line[0] #) { continue; // #EXTVLCOPT 之类的附加指令直接跳过 } if ($name ! ) { $out[$name] $line; // 同名频道后出现的覆盖前面的 $name ; } } return $out; } }preg_split同时兼容\r\n和\n因为很多源是在 Windows 上生成的。stripos(...) 0判断行首用是因为stripos返回 0 时用判断会踩假值坑。同名覆盖这个策略是刻意的——一个源文件里同一个频道出现多次很常见后面的通常是更新过的地址。解析完还不算完地址可能是相对路径要拼上源站域名这一步漏了客户端拿到的就是/live/xxx.m3u8这种废地址。聚合到频道列表时主源取不到就降级到备用源两条都没有的直接不下发public function lists(int $groupId 0): array { $key ch:list: . $groupId; $data cache($key); if ($data) { return $data; } $query db(iptv_channel)-where(status, 1)-order(sort desc, id asc); if ($groupId 0) { $query-where(group_id, $groupId); } $rows $query-field(id,name,group_id,logo,source_id,backup_source_id)-select(); $data []; foreach ($rows as $r) { $url $this-resolveUrl($r[source_id]); // 先试主源 if (!$url) { $url $this-resolveUrl($r[backup_source_id]); // 主源挂了走备用 } if (!$url) { continue; // 两条都不可用这个频道不下发避免客户端点进去转圈 } $data[] [ id $r[id], name $r[name], logo $r[logo], url $this-sign-signUrl($url, 600), // 10 分钟有效期 ]; } cache($key, $data, 300); // 缓存 5 分钟别让每个用户都打库 return $data; }$groupId 0表示取全部分类APP 首页一般先请求一次全量再本地分组比每个分类发一次请求省事。缓存 300 秒是个经验值太短起不到作用太长后台改了频道要等五分钟才生效调试阶段可以把cache()那行先注释掉。注意signUrl的 TTL 用的是 600 秒比缓存时间长是为了让缓存里的地址在缓存有效期内始终可用这个配合关系别搞反。3.3 播放地址签名防盗链与过期控制频道列表里的地址如果原样给出被人抓一次包就能长期白用你的带宽。常见做法是给地址加过期时间和签名由 Nginx 或 CDN 侧做校验?php namespace app\api\service; class SignService { /** * param string $url 原始播放地址 * param int $ttl 有效秒数至少覆盖一次完整观看 */ public function signUrl(string $url, int $ttl 600): string { $expire time() $ttl; $path parse_url($url, PHP_URL_PATH) ?: /; // 只签路径不签 query $sign md5(config(app.play_key) . $path . $expire); $sep strpos($url, ?) false ? ? : ; return $url . $sep . e . $expire . sign . $sign; } }只对path签名而不是整个 URL是因为原始地址常常自带?tokenxxx这类参数整个签会把 query 顺序也算进去源站稍微一变签名就全废。$ttl给 600 秒看着短但要想到 HLS 是分片播放客户端每拉一个 ts 分片都会带着同一个 manifest 地址参数如果只签 manifest 不签分片600 秒够看很久如果分片地址也要单独签那 TTL 就得按单次观看时长来给至少 3600 秒否则看到一半断流这个现象在第 5 章会展开。密钥play_key要和 Nginx 的secure_link_md5或 CDN 的鉴权配置完全一致字符差一个就是全量 403。4. APP 端链路从列表点击到 HLS 起播的三个关键封装后端把数据准备好之后客户端的活儿看着轻其实坑都藏在生命周期里。列表刷新、图片加载、播放器创建销毁、切台时的资源回收哪一环没处理好表现出来都是「用一会儿就卡」。这一章按一次真实点击的顺序走一遍。4.1 列表页RecyclerView 绑定与本地缓存频道列表一般用 RecyclerView 加 GridLayoutManager三列或四列。Adapter 写起来没什么花活关键是别在onBindViewHolder里做网络请求或解析public class ChannelAdapter extends RecyclerView.AdapterChannelAdapter.VH { private final ListChannel data new ArrayList(); private final OnItemClick click; public interface OnItemClick { void onPlay(Channel c); } public ChannelAdapter(OnItemClick click) { this.click click; } public void submit(ListChannel list) { data.clear(); data.addAll(list); notifyDataSetChanged(); // 几百条规模全量刷新够用 } Override public VH onCreateViewHolder(ViewGroup parent, int type) { View v LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_channel, parent, false); return new VH(v); } Override public void onBindViewHolder(VH h, int pos) { Channel c data.get(pos); h.name.setText(c.name); Glide.with(h.logo) .load(c.logo) .placeholder(R.mipmap.ic_channel) .into(h.logo); h.itemView.setOnClickListener(v - click.onPlay(c)); } Override public int getItemCount() { return data.size(); } static class VH extends RecyclerView.ViewHolder { TextView name; ImageView logo; VH(View v) { super(v); name v.findViewById(R.id.tv_name); logo v.findViewById(R.id.iv_logo); } } }submit里用notifyDataSetChanged而不是 DiffUtil是因为频道列表一次全量替换没有局部更新场景用 DiffUtil 反而增加复杂度。台标一定要给placeholder否则图片没加载出来那几秒是一片白块观感很差。列表数据建议在SharedPreferences或本地文件里存一份 JSON冷启动先渲染缓存再请求网络这样断网时至少还能看到上次的列表——很多人第一次打开 App 看到空白页就是没做这层兜底。4.2 播放器封装ExoPlayer 生命周期与切台不黑屏播放器这块最容易翻车。最常见的写法是每次点击new ExoPlayer退出页面不 release切十几个台之后内存飙升、解码器被占满直接黑屏。正确姿势是单例复用切台只换 MediaItempublic class PlayerHolder { private ExoPlayer player; private final PlayerView view; public PlayerHolder(PlayerView view) { this.view view; } public void play(String url) { if (player null) { player new ExoPlayer.Builder(view.getContext()) .setLoadControl(new DefaultLoadControl.Builder() // 最小缓冲 3s、最大 15s、起播 1s、断流重缓冲 2s .setBufferDurationsMs(3000, 15000, 1000, 2000) .build()) .build(); view.setPlayer(player); } player.stop(); // 先停旧的再换新的 player.setMediaItem(MediaItem.fromUri(Uri.parse(url))); // 带签名的新地址 player.prepare(); player.setPlayWhenReady(true); } public void release() { if (player ! null) { player.release(); player null; view.setPlayer(null); // 必须解绑否则 Surface 泄漏 } } }setMediaItem是 ExoPlayer 2.13 之后的 API老包里可能是prepare(mediaSource)那种写法改造时注意版本。setBufferDurationsMs四个参数依次是最小缓冲、最大缓冲、起播缓冲、重缓冲弱网环境下把起播缓冲调小能让首帧更快出来代价是更容易卡顿要按自己源的质量调。release()里view.setPlayer(null)这一行很多人漏掉PlayerView 持有 Surface不解绑就是实打实的内存泄漏反复进出播放页几次就 OOM。生命周期上onPause调player.pause()onDestroy调release()别在onStop里 release切后台回来看个消息就得重新缓冲。4.3 联调改 baseUrl、打 release 包、真机验证APP 连后端只有一处要改就是接口根地址。这类工程一般把它抽在ApiConfig或Constants里public class ApiConfig { // 真机调试写局域网 IP写 localhost 手机连的是它自己 public static final String BASE_URL http://192.168.1.20:8000/api/v1/; public static final String HEADER_TOKEN X-Token; }192.168.1.20换成你开发机在局域网里的实际地址电脑和手机必须在同一个网段。Android 9 以后默认禁止明文 HTTPAndroidManifest.xml里要加android:usesCleartextTraffictrue或者配 network security config只对调试域名放行。打包用./gradlew assembleReleasedebug 包和 release 包的签名、混淆配置不一样有些包在 release 下因为混淆把反射用到的 model 字段名改掉了JSON 解析全变 null这个必须在真机上用 release 包验一遍别只在模拟器 debug 跑通就交付。5. 避坑排查五个把人卡到半夜的现场问题下面这几条都是这类双端项目里高频出现的现场故障按「现象 → 原因 → 解决」写遇到了可以直接对照。5.1 接口 200 但频道列表是空的现象Postman 请求频道接口返回code: 0data是空数组日志里也没有报错。原因九成出在源解析。要么.m3u文件里的地址是相对路径解析出来是/live/xxx.m3u8resolveUrl拼域名那步没做客户端拿到废地址要么源地址后缀不是.m3u8而聚合逻辑里写死了只认.m3u8结尾把.flv、.ts全过滤掉了还有一种情况是频道表status字段默认 0导入数据时没置 1查询条件where(status, 1)直接把全部记录筛没了。解决在parseM3u之后、入库之前打个日志把解析出的前十条地址打出来看格式resolveUrl里判断strpos($url, http) ! 0就补源站域名去掉后缀过滤改成协议白名单http/https导入 SQL 之后跑一句UPDATE iptv_channel SET status 1。这三步查完基本能定位。5.2 起播只有声音没有画面现象点进频道声音正常出来画面一直黑或者停在台标进度条在走。原因编码格式不支持。源是 H.265/HEVC 编码而工程里引的 ExoPlayer 版本没开 HEVC 软解或者干脆是缺exoplayer-hls扩展core 模块不认 m3u8 的切片描述音频轨能勉强解析出来视频轨直接失败。解决先看build.gradle里有没有exoplayer-hls没有就加加了还不行用ffprobe看一下源的编码H.265 的话要么换源要么在 ExoPlayer 里配置DefaultRenderersFactory.setExtensionRendererMode(EXTENSION_RENDERER_MODE_PREFER)并引入 ffmpeg 扩展。最省事的办法是后端做一层转码统一输出 H.264 的 HLS客户端就不用管编码差异了代价是服务器要扛转码开销。5.3 真机连不上后端现象模拟器一切正常装到真机上所有接口超时APP 里提示网络错误。原因三个高频点。一是BASE_URL写的localhost或127.0.0.1在手机上指向手机自己二是后端php think run没加-H 0.0.0.0只监听本机回环三是开发机防火墙拦了 8000 端口或者手机和电脑不在同一个 Wi-Fi一个连 5G 一个连 2.4G跨了网段。解决先用手机浏览器直接访问http://开发机IP:8000/api/v1/channel能看到 JSON 说明网络通问题在 APP看不到就查后端监听地址和防火墙。Windows 上netstat -ano | findstr 8000看监听的是不是0.0.0.0:8000是127.0.0.1:8000就说明启动参数不对。5.4 切台几次就黑屏或卡死现象连续切十几个频道之后画面不再刷新或者直接闪退日志里有OutOfMemoryError或者MediaCodec相关报错。原因每次播放都new ExoPlayer页面退出没release解码器实例越积越多。Android 的硬件解码器数量有限超过上限就申请不到新的表现就是黑屏。解决按 4.2 节的写法改成单例复用play()里先stop()再换MediaItemrelease()里解绑PlayerView。可以用 Android Studio 的 Profiler 看内存曲线正常复用的情况下切台内存是一条平稳的线不复用就是一格格往上跳。5.5 播到一半突然断开现象频道看五六分钟之后画面卡住重连几次又好了或者直接报 403。原因播放地址签名过期。签名 TTL 设了 600 秒但 HLS 播放器在播放过程中会不断请求新的分片如果分片地址也走同一套签名校验超过 600 秒之后的请求全部 403。另一种情况是 CDN 侧的时间和你服务器时间差了十几分钟签名算出来的过期时间在 CDN 看来已经过了。解决把 TTL 调到覆盖单次观看时长比如 3600 秒或者只对 manifest 签名分片地址由 manifest 里的相对路径推导不单独校验。服务器装个 NTP 同步时间CDN 和源站时间差控制在 1 分钟内。查的时候看 Nginx 访问日志里 403 的请求是不是都带着同一个e参数是的话就是过期不是的话再查签名算法对不对。6. 二次开发换源、加分类、加缓存的三步改法拿到这套东西之后最常见的三个改造方向按投入产出比排一下改造点改哪里工作量收益换自有源SourceService::parseM3u 后台导入半天直接决定能不能用加频道分类iptv_group表 列表接口 group 参数一天客户端体验提升明显加接口缓存lists()里的 cache Redis 配置两小时并发上来不崩换源是最先要做的。后台一般有「导入 m3u」的入口把文件传上去走一遍解析入库。如果你的源格式不标准比如描述行里没有tvg-name只有,频道名那 3.2 节的解析器已经兜住了。导入之后别急着在 APP 上看先在数据库里SELECT COUNT(*) FROM iptv_channel WHERE status 1确认数量对得上再清一次缓存cache(ch:list:0, null)不然看到的还是旧列表。加分类要动两处数据库加iptv_group表存分类名和排序接口的lists($groupId)已经支持按分类筛只要把group_id传对就行。APP 端在顶部加一个横向的 TabLayout切换时带上group_id重新请求。这里有个小技巧首次进页面请求一次全量groupId0客户端自己按group_id分组渲染 Tab切 Tab 不发请求体验会顺很多代价是首屏数据量稍大几百条 JSON 也就几十 KB。加缓存则是并发上来的救命稻草。默认的 file 缓存单机还行多实例部署就得换 Redis。改config/cache.php的type为redis填上地址和密码lists()里那两行cache()不用动。验证方法很直接压测工具打 100 并发看数据库的SHOW PROCESSLIST里是不是只有零星几条查询如果还是满屏的 SELECT说明缓存没命中八成是 key 拼错了或者缓存驱动没生效。我自己的习惯是每次改完接口先用 curl 打一遍把返回的 JSON 存成文件改完再打一遍做diff字段少了、类型变了立刻能看出来——这比在 APP 上点半天靠谱得多。有一次就是靠这个 diff 发现加分类之后logo字段被漏选了客户端台标全空查了半小时以为是图片服务挂了。从那以后我每次动列表接口都强制走一遍 curl diff再上真机。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表