# 短剧统一 API 对接文档 > 面向 Android、iOS、H5 及其他 APP 客户端的完整接入手册。 - **文档版本**:v1.1 - **更新日期**:2026-08-10 - **服务域名**:`https://api.xueyuanpie.com` - **接口前缀**:`/app/playlet` - **请求方式**:全部为 `POST` - **请求格式**:`application/json; charset=UTF-8` - **响应格式**:`application/json; charset=UTF-8` - **鉴权方式**:当前 5 个接口均标记 `@TokenIgnore`,暂不需要 Token - **字符编码**:UTF-8 > 当前公网域名与 HTTPS 已配置完成。若短剧宿主服务尚未启动,接口会返回 HTTP `503` 和明确的 JSON 提示;这表示 API 后端未部署,不是请求参数错误。 --- ## 1. 快速开始 ### 1.1 最小请求示例 ```bash curl -X POST 'https://api.xueyuanpie.com/app/playlet/search' \ -H 'Content-Type: application/json; charset=UTF-8' \ -d '{"platform":0,"keyword":"重生","page":1,"size":20}' ``` ### 1.2 基本调用链路 ```text 选择平台 ↓ 获取分类 categories(可选) ↓ 分页列表 page / 搜索 search ↓ 详情与剧集 detail ↓ 若 episodes[].playUrl 为空,则调用 play ↓ 播放 url;失败时依次尝试 backupUrls ``` ### 1.3 客户端必须遵守的三条规则 1. `sourceId`、`episodeId` 必须按 **字符串** 保存和传输。 2. 调用详情和播放时,必须沿用列表结果对应的同一个 `platform`。 3. 播放地址可能带时效签名,应在即将播放时获取,不要永久缓存。 --- ## 2. 平台编码 请求中的 `platform` 使用整数编码;响应中短剧条目的 `platform` 使用枚举字符串。 | 请求值 | 平台 | 响应枚举名 | 分类能力 | |---:|---|---|---| | `0` | 红果短剧 | `HONGGUO` | 上游可能无独立分类,允许返回空数组 | | `1` | 七猫短剧 | `QIMAO` | 支持分类 | | `2` | 河马短剧 | `HEMA` | 支持分类 | ### 2.1 ID 精度说明 上游 ID 可能超过 JavaScript 安全整数上限 `9007199254740991`。即使某个平台当前返回较短的数字,也必须统一视为字符串。 正确: ```json { "sourceId": "7594403964322335768", "episodeId": "615427457" } ``` 错误: ```json { "sourceId": 7594403964322335768, "episodeId": 615427457 } ``` Java/Kotlin 使用 `String`;TypeScript 使用 `string`;Swift 使用 `String`,不要使用 `Long`、`Double` 或 JavaScript `number` 承载这些字段。 --- ## 3. 请求约定 ### 3.1 HTTP 头 ```http Content-Type: application/json; charset=UTF-8 Accept: application/json ``` 当前不需要 `Authorization`。如果生产环境后续启用鉴权,应由服务端另行约定 Header,不建议客户端预写固定 Token。 ### 3.2 分页参数归一化 Controller 会对分页参数做以下处理: | 参数情况 | 实际处理 | |---|---| | 不传 `page` | 使用 `1` | | `page < 1` | 按 `1` 处理 | | 不传 `size` | 使用 `20` | | `size < 1` | 按 `1` 处理 | | `size > 50` | 按 `50` 处理 | 客户端仍应主动传入合法值,不要依赖服务端修正。 ### 3.3 空值兼容 上游平台字段完整度不同,以下字段都可能为 `null`、空字符串或空数组: - 封面、简介、评分、播放量、完结状态; - 总集数、剧集封面、剧集时长、清晰度; - 详情中的直接播放地址; - 备用播放地址。 UI 应提供默认封面、默认文案和空状态,不应因单个可选字段为空而导致页面崩溃。 --- ## 4. 通用响应结构 接口使用宿主 Cool 项目的统一 `R` 结构,成功业务数据位于 `data`。 ```json { "code": 1000, "message": "success", "data": {} } ``` | 字段 | 类型 | 必有 | 说明 | |---|---|---:|---| | `code` | integer | 是 | 业务状态码;示例成功值为 `1000` | | `message` | string | 否 | 提示信息;部分宿主版本可能序列化为 `msg` | | `msg` | string | 否 | 兼容字段,与 `message` 二选一读取 | | `data` | object/array/null | 是 | 业务数据;失败时通常为 `null` | > 最终成功码和提示字段名称以实际部署的 Cool 宿主项目 `R` 序列化为准。客户端应集中封装,不要在每个页面重复判断。 ### 4.1 推荐判断顺序 1. 检查是否发生 DNS、TLS、连接或超时错误; 2. 检查 HTTP 状态是否为 `2xx`; 3. 尝试解析 JSON; 4. 根据 `code` 判断业务是否成功; 5. 成功后读取 `data`; 6. 失败文案优先读取 `message`,为空再读取 `msg`; 7. 未知错误显示通用提示,并记录请求路径、HTTP 状态、业务码和 trace 信息,禁止记录播放签名或敏感配置。 ### 4.2 后端未部署响应 当公网 Nginx 可用、但 `127.0.0.1:18082` 没有短剧服务监听时,返回: ```http HTTP/1.1 503 Service Unavailable Content-Type: application/json Cache-Control: no-store ``` ```json { "code": 503, "message": "短剧 API 后端尚未部署,请稍后重试", "data": null } ``` 此响应不能按业务成功处理。客户端可展示“服务暂不可用”,不应无限重试。 ### 4.3 HTTP 状态处理建议 | HTTP 状态 | 含义 | 客户端建议 | |---:|---|---| | `200` | 请求已到达业务服务 | 继续判断业务 `code` | | `400` | JSON 或请求参数不合法 | 不自动重试,检查请求体 | | `404` | 路由不存在 | 检查 URL、接口前缀和版本 | | `429` | 请求过于频繁 | 按 `Retry-After` 或指数退避重试 | | `500` | 服务内部异常 | 提示稍后重试并上报日志 | | `502` | 网关连接上游失败 | 短暂退避后有限重试 | | `503` | 服务未部署或暂不可用 | 展示服务不可用,避免高频重试 | | `504` | 网关等待服务超时 | 查询接口可有限重试,播放接口谨慎重试 | --- ## 5. 数据模型与字段字典 ## 5.1 CategoryTab(分类分组) 分类接口使用宿主项目已有的 `CategoryTabVo`。典型结构如下: ```json { "category": "题材", "items": [ { "id": "1273", "name": "都市", "current": false } ] } ``` 由于该模型来自宿主模块,不同版本可能存在字段命名差异。客户端初次联调时应以实际 JSON 为准。分类项的 `id` 必须按字符串保存,并原样作为 `page.categoryId` 传回。 ## 5.2 PlayletItem(短剧条目) ```json { "platform": "QIMAO", "sourceId": "41000288558", "title": "短剧名称", "cover": "https://example.com/cover.jpg", "introduction": "剧情简介", "episodeCount": 80, "score": "9.3", "playCount": "1200万", "tags": ["都市", "重生"], "finishStatus": "已完结" } ``` | 字段 | 类型 | 可空 | 说明 | |---|---|---:|---| | `platform` | string | 是 | 平台枚举:`HONGGUO` / `QIMAO` / `HEMA` | | `sourceId` | string | 是 | 上游短剧 ID;详情和播放链路的关键参数 | | `title` | string | 是 | 短剧名称 | | `cover` | string | 是 | 封面 URL;客户端需准备占位图 | | `introduction` | string | 是 | 剧情简介 | | `episodeCount` | integer | 是 | 总集数,未知时可能为空 | | `score` | string | 是 | 上游评分原文,不保证可转数字 | | `playCount` | string | 是 | 上游播放量或热度原文,如“1200万” | | `tags` | string[] | 否 | 题材标签;默认空数组 | | `finishStatus` | string | 是 | 完结状态原文,如“已完结”“连载中” | ## 5.3 Episode(剧集) ```json { "sourceEpisodeId": "615427457", "episodeNumber": 1, "title": "第1集", "cover": "https://example.com/episode-1.jpg", "durationSeconds": 125, "playable": true, "playUrl": null, "quality": "720P" } ``` | 字段 | 类型 | 可空 | 说明 | |---|---|---:|---| | `sourceEpisodeId` | string | 是 | 上游剧集 ID;作为 `play.episodeId` 传入 | | `episodeNumber` | integer | 是 | 剧集序号;详情结果默认升序 | | `title` | string | 是 | 剧集标题 | | `cover` | string | 是 | 剧集封面 URL | | `durationSeconds` | integer | 是 | 时长,单位秒 | | `playable` | boolean | 否 | 是否具备播放条件,默认 `true` | | `playUrl` | string | 是 | 详情直接提供的地址;为空时调用 `/play` | | `quality` | string | 是 | 上游清晰度标识,如 `720P` | > `playable=false` 时,客户端应禁用播放按钮或提示该集暂不可播,不要强行调用播放器。 ## 5.4 PlayletDetail(详情与剧集) ```json { "detail": { "platform": "HEMA", "sourceId": "41000288558", "title": "短剧名称", "cover": "https://example.com/cover.jpg", "introduction": "剧情简介", "episodeCount": 80, "score": null, "playCount": null, "tags": ["都市"], "finishStatus": "已完结" }, "episodes": [] } ``` | 字段 | 类型 | 可空 | 说明 | |---|---|---:|---| | `detail` | PlayletItem | 是 | 平台未返回有效详情时可能为 `null` | | `episodes` | Episode[] | 否 | 剧集列表,默认空数组并按集数升序 | ## 5.5 PlayletPlay(播放信息) ```json { "sourceEpisodeId": "615427457", "url": "https://example.com/video.m3u8?sign=...", "quality": "720P", "format": "m3u8", "backupUrls": [ "https://backup.example.com/video.m3u8?sign=..." ] } ``` | 字段 | 类型 | 可空 | 说明 | |---|---|---:|---| | `sourceEpisodeId` | string | 是 | 对应请求中的剧集 ID | | `url` | string | 是 | 首选播放地址 | | `quality` | string | 是 | 清晰度标识 | | `format` | string | 是 | 封装格式,如 `mp4`、`m3u8` | | `backupUrls` | string[] | 否 | 备用地址,默认空数组 | `url` 为空不代表 HTTP 请求失败,但客户端无法开始播放,应提示“暂未获取到播放地址”,并允许用户稍后手动重试。 ## 5.6 PageResult(分页结果) ```json { "list": [], "pagination": { "page": 1, "size": 20, "total": 99999 } } ``` | 字段 | 类型 | 说明 | |---|---|---| | `list` | PlayletItem[] | 当前页数据 | | `pagination.page` | integer | 当前页码 | | `pagination.size` | integer | 每页条数 | | `pagination.total` | integer | 总记录数;可能为未知总数占位值 `99999` | 部分上游不返回精确总记录数,服务端会使用 `99999`。因此客户端不要使用 `page * size >= total` 作为唯一的结束条件。 推荐结束判断: ```text 本页 list 为空 → 已到底 本页 list.size < 请求 size → 通常已到底 本页 list.size == 请求 size → 可以继续请求下一页 ``` 如上游最后一页刚好等于 `size`,客户端可能多请求一次空页,这是可接受的。 --- ## 6. 接口总览 | 接口 | 路径 | 主要用途 | 返回 data | |---|---|---|---| | 获取分类 | `/app/playlet/categories` | 获取平台分类筛选项 | `CategoryTab[]` | | 分页列表 | `/app/playlet/page` | 推荐或分类短剧列表 | `PageResult` | | 搜索短剧 | `/app/playlet/search` | 按关键词搜索 | `PageResult` | | 详情剧集 | `/app/playlet/detail` | 获取短剧信息和剧集 | `PlayletDetail` | | 播放信息 | `/app/playlet/play` | 获取指定剧集播放地址 | `PlayletPlay` | --- ## 7. 接口详情 ## 7.1 获取平台分类 ```http POST /app/playlet/categories ``` ### 请求参数 | 字段 | 类型 | 必填 | 允许值 | 说明 | |---|---|---:|---|---| | `platform` | integer | 是 | `0` / `1` / `2` | 平台编码 | ### 请求示例 ```json { "platform": 1 } ``` ### 完整成功响应示例 ```json { "code": 1000, "message": "success", "data": [ { "category": "题材", "items": [ { "id": "1273", "name": "都市", "current": false }, { "id": "1274", "name": "重生", "current": false } ] } ] } ``` ### 空分类响应 红果或上游未提供独立分类时,正常返回空数组,不属于错误: ```json { "code": 1000, "message": "success", "data": [] } ``` ### cURL ```bash curl -X POST 'https://api.xueyuanpie.com/app/playlet/categories' \ -H 'Content-Type: application/json; charset=UTF-8' \ -d '{"platform":1}' ``` ### 客户端处理 - 分类为空时隐藏分类栏,直接调用分页列表; - 分类 ID 原样以字符串传入 `page.categoryId`; - 切换平台时清空旧平台的分类选择和分页状态。 --- ## 7.2 分页获取短剧列表 ```http POST /app/playlet/page ``` ### 请求参数 | 字段 | 类型 | 必填 | 默认值 | 说明 | |---|---|---:|---:|---| | `platform` | integer | 是 | - | 平台编码 | | `categoryId` | string | 否 | `null` | 分类接口返回的 ID;红果可不传 | | `page` | integer | 否 | `1` | 页码,最小 `1` | | `size` | integer | 否 | `20` | 每页条数,范围 `1~50` | ### 请求示例 ```json { "platform": 1, "categoryId": "1273", "page": 1, "size": 20 } ``` 不使用分类时: ```json { "platform": 0, "page": 1, "size": 20 } ``` ### 完整成功响应示例 ```json { "code": 1000, "message": "success", "data": { "list": [ { "platform": "QIMAO", "sourceId": "41000288558", "title": "重生后我走向人生巅峰", "cover": "https://example.com/cover.jpg", "introduction": "一段示例剧情简介", "episodeCount": 80, "score": "9.3", "playCount": "1200万", "tags": ["都市", "重生"], "finishStatus": "已完结" } ], "pagination": { "page": 1, "size": 20, "total": 99999 } } } ``` ### cURL ```bash curl -X POST 'https://api.xueyuanpie.com/app/playlet/page' \ -H 'Content-Type: application/json; charset=UTF-8' \ -d '{"platform":1,"categoryId":"1273","page":1,"size":20}' ``` ### 客户端处理 - 首次进入、切换平台或切换分类时把 `page` 重置为 `1`; - 刷新操作替换列表,加载更多操作追加列表; - 可用 `platform + sourceId` 作为跨平台唯一键; - 不要只用 `sourceId` 去重,因为不同平台的命名空间可能重合; - 使用当前页数据量判断是否还能加载,不能完全依赖 `total`。 --- ## 7.3 搜索短剧 ```http POST /app/playlet/search ``` ### 请求参数 | 字段 | 类型 | 必填 | 默认值 | 说明 | |---|---|---:|---:|---| | `platform` | integer | 是 | - | 平台编码 | | `keyword` | string | 是 | - | 搜索词,去除首尾空白后不得为空 | | `page` | integer | 否 | `1` | 页码,最小 `1` | | `size` | integer | 否 | `20` | 每页条数,范围 `1~50` | ### 请求示例 ```json { "platform": 0, "keyword": "重生", "page": 1, "size": 20 } ``` ### 完整成功响应示例 ```json { "code": 1000, "message": "success", "data": { "list": [ { "platform": "HONGGUO", "sourceId": "7594403964322335768", "title": "重生之示例短剧", "cover": "https://example.com/hongguo-cover.jpg", "introduction": "搜索结果示例", "episodeCount": 60, "score": null, "playCount": "热播", "tags": ["重生"], "finishStatus": "已完结" } ], "pagination": { "page": 1, "size": 20, "total": 99999 } } } ``` ### 空结果响应 ```json { "code": 1000, "message": "success", "data": { "list": [], "pagination": { "page": 1, "size": 20, "total": 99999 } } } ``` ### 参数错误示意 业务错误的具体 `code` 由宿主项目决定,提示内容如下: ```json { "code": 400, "message": "搜索关键词不能为空", "data": null } ``` ### cURL ```bash curl -X POST 'https://api.xueyuanpie.com/app/playlet/search' \ -H 'Content-Type: application/json; charset=UTF-8' \ -d '{"platform":0,"keyword":"重生","page":1,"size":20}' ``` ### 客户端处理 - 输入为空时在客户端直接拦截; - 建议输入停止 `300~500ms` 后再发起搜索; - 新关键词搜索前取消旧请求,防止旧响应覆盖新结果; - 搜索历史应只保存关键词,不保存短时播放 URL; - 切换平台后重新搜索,并清空旧平台结果。 --- ## 7.4 获取详情和剧集列表 ```http POST /app/playlet/detail ``` ### 请求参数 | 字段 | 类型 | 必填 | 说明 | |---|---|---:|---| | `platform` | integer | 是 | 必须与列表结果来源平台一致 | | `sourceId` | string | 是 | 列表或搜索结果返回的短剧 ID | ### 请求示例 ```json { "platform": 2, "sourceId": "41000288558" } ``` ### 完整成功响应示例 ```json { "code": 1000, "message": "success", "data": { "detail": { "platform": "HEMA", "sourceId": "41000288558", "title": "示例短剧", "cover": "https://example.com/cover.jpg", "introduction": "剧情简介", "episodeCount": 2, "score": "9.0", "playCount": "100万", "tags": ["都市"], "finishStatus": "已完结" }, "episodes": [ { "sourceEpisodeId": "615427457", "episodeNumber": 1, "title": "第1集", "cover": "https://example.com/episode-1.jpg", "durationSeconds": 125, "playable": true, "playUrl": null, "quality": "720P" }, { "sourceEpisodeId": "615427458", "episodeNumber": 2, "title": "第2集", "cover": null, "durationSeconds": 132, "playable": true, "playUrl": "https://example.com/direct-video.m3u8?sign=...", "quality": "720P" } ] } } ``` ### cURL ```bash curl -X POST 'https://api.xueyuanpie.com/app/playlet/detail' \ -H 'Content-Type: application/json; charset=UTF-8' \ -d '{"platform":2,"sourceId":"41000288558"}' ``` ### 客户端处理 - `episodes` 默认按 `episodeNumber` 升序,但客户端仍可做防御性排序; - `playable=false` 的剧集不发起播放; - `playUrl` 非空时可直接播放,失败后再调用 `/play` 刷新地址; - `playUrl` 为空时,把 `sourceEpisodeId` 作为 `/play` 的 `episodeId`; - `detail=null` 但 `episodes` 非空时仍可展示基础剧集页; - `episodes=[]` 时展示“暂无可用剧集”,不要访问数组第一项。 --- ## 7.5 获取播放信息 ```http POST /app/playlet/play ``` ### 请求参数 | 字段 | 类型 | 必填 | 说明 | |---|---|---:|---| | `platform` | integer | 是 | 与详情请求保持一致 | | `sourceId` | string | 是 | 短剧 ID | | `episodeId` | string | 是 | `episodes[].sourceEpisodeId` | ### 请求示例 ```json { "platform": 2, "sourceId": "41000288558", "episodeId": "615427457" } ``` ### 完整成功响应示例 ```json { "code": 1000, "message": "success", "data": { "sourceEpisodeId": "615427457", "url": "https://example.com/video.m3u8?sign=...", "quality": "720P", "format": "m3u8", "backupUrls": [ "https://backup-1.example.com/video.m3u8?sign=...", "https://backup-2.example.com/video.m3u8?sign=..." ] } } ``` ### 无可用地址示例 ```json { "code": 1000, "message": "success", "data": { "sourceEpisodeId": "615427457", "url": null, "quality": null, "format": null, "backupUrls": [] } } ``` ### cURL ```bash curl -X POST 'https://api.xueyuanpie.com/app/playlet/play' \ -H 'Content-Type: application/json; charset=UTF-8' \ -d '{"platform":2,"sourceId":"41000288558","episodeId":"615427457"}' ``` ### 推荐播放策略 1. 用户点击某集后检查 `playable`; 2. 若详情中的 `playUrl` 非空,先尝试该地址; 3. 否则调用 `/play`; 4. 优先使用返回的 `url`; 5. 主地址发生网络错误、403、404 或媒体解析失败时,按顺序尝试 `backupUrls`; 6. 全部失败后允许用户点击“重新加载”,重新调用 `/play` 获取新签名; 7. 切换剧集时取消上一集尚未完成的播放地址请求。 ### 播放器注意事项 - `m3u8` 通常使用 HLS 播放;Android 推荐 Media3/ExoPlayer,iOS 推荐 AVPlayer; - 不要只根据 URL 后缀判断格式,优先参考 `format`,必要时结合响应 Content-Type; - 某些 CDN 可能校验 User-Agent、Referer 或时效签名,遇到 `403` 应先重新获取地址; - 不要把完整带签名播放 URL 写入公开日志、埋点参数或崩溃报告; - 不建议后台预取大量剧集播放地址,避免签名过期和上游压力。 --- ## 8. 参数校验与典型错误 Controller 当前具备以下校验: | 场景 | 提示文案 | |---|---| | 未传 `platform` | `短剧平台不能为空` | | `platform` 不是 `0/1/2` | `不支持的短剧平台: {code}` | | 搜索词为空 | `搜索关键词不能为空` | | 详情未传 `sourceId` | `短剧ID不能为空` | | 播放未传 `sourceId` | `短剧ID不能为空` | | 播放未传 `episodeId` | `剧集ID不能为空` | 业务错误码的具体数字由 Cool 宿主的异常处理器决定,因此客户端不要硬编码“某段文案必然对应某个数字”。 ### 8.1 重试原则 | 请求 | 是否建议自动重试 | 建议 | |---|---|---| | 分类、列表、搜索、详情 | 有条件 | 只对连接失败、超时、502/504 重试 1~2 次 | | 播放信息 | 谨慎 | 最多重试 1 次;用户操作优先 | | 400、业务参数错误 | 否 | 修正请求参数后再发起 | | 404 | 否 | 检查路径或部署版本 | | 429 | 是 | 遵循服务端等待时间并指数退避 | | 503 | 不立即重试 | 提示服务暂不可用,稍后由用户重试 | 推荐退避:第一次等待约 `500ms`,第二次约 `1500ms`,加入少量随机抖动。不要无限循环。 --- ## 9. Android / Kotlin 完整示例 以下示例使用 Retrofit、OkHttp 和 Gson 风格模型。若项目使用 Moshi 或 Kotlinx Serialization,可保持字段类型不变。 ### 9.1 请求模型 ```kotlin data class PlatformRequest( val platform: Int ) data class PageRequest( val platform: Int, val categoryId: String? = null, val page: Int = 1, val size: Int = 20 ) data class SearchRequest( val platform: Int, val keyword: String, val page: Int = 1, val size: Int = 20 ) data class DetailRequest( val platform: Int, val sourceId: String ) data class PlayRequest( val platform: Int, val sourceId: String, val episodeId: String ) ``` ### 9.2 响应模型 ```kotlin data class ApiResponse( val code: Int, val message: String? = null, val msg: String? = null, val data: T? = null ) { fun displayMessage(): String = message ?: msg ?: "请求失败" } data class Pagination( val page: Long = 1, val size: Long = 20, val total: Long = 0 ) data class PageResult( val list: List = emptyList(), val pagination: Pagination = Pagination() ) data class PlayletItem( val platform: String? = null, val sourceId: String? = null, val title: String? = null, val cover: String? = null, val introduction: String? = null, val episodeCount: Int? = null, val score: String? = null, val playCount: String? = null, val tags: List = emptyList(), val finishStatus: String? = null ) data class Episode( val sourceEpisodeId: String? = null, val episodeNumber: Int? = null, val title: String? = null, val cover: String? = null, val durationSeconds: Long? = null, val playable: Boolean = true, val playUrl: String? = null, val quality: String? = null ) data class PlayletDetail( val detail: PlayletItem? = null, val episodes: List = emptyList() ) data class PlayletPlay( val sourceEpisodeId: String? = null, val url: String? = null, val quality: String? = null, val format: String? = null, val backupUrls: List = emptyList() ) ``` ### 9.3 Retrofit 接口 ```kotlin interface PlayletApi { @POST("app/playlet/categories") suspend fun categories( @Body body: PlatformRequest ): ApiResponse>> @POST("app/playlet/page") suspend fun page( @Body body: PageRequest ): ApiResponse> @POST("app/playlet/search") suspend fun search( @Body body: SearchRequest ): ApiResponse> @POST("app/playlet/detail") suspend fun detail( @Body body: DetailRequest ): ApiResponse @POST("app/playlet/play") suspend fun play( @Body body: PlayRequest ): ApiResponse } ``` ### 9.4 Retrofit 初始化 ```kotlin val okHttpClient = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() val api = Retrofit.Builder() .baseUrl("https://api.xueyuanpie.com/") .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() .create(PlayletApi::class.java) ``` ### 9.5 详情到播放示例 ```kotlin suspend fun resolveEpisodeUrl( api: PlayletApi, platform: Int, sourceId: String, episode: Episode ): List { if (!episode.playable) return emptyList() // 详情已直接给出地址时先使用它。 episode.playUrl?.takeIf { it.isNotBlank() }?.let { return listOf(it) } val episodeId = episode.sourceEpisodeId ?: return emptyList() val response = api.play( PlayRequest(platform, sourceId, episodeId) ) if (response.code != 1000) { throw IllegalStateException(response.displayMessage()) } val play = response.data ?: return emptyList() return buildList { play.url?.takeIf { it.isNotBlank() }?.let(::add) addAll(play.backupUrls.filter { it.isNotBlank() }) }.distinct() } ``` > 如果实际宿主成功码不是 `1000`,应把成功判断集中配置,而不是散落在业务代码中。 --- ## 10. Java / OkHttp 示例 ```java MediaType JSON = MediaType.get("application/json; charset=utf-8"); String bodyJson = "{\"platform\":2,\"sourceId\":\"41000288558\"}"; Request request = new Request.Builder() .url("https://api.xueyuanpie.com/app/playlet/detail") .header("Accept", "application/json") .post(RequestBody.create(bodyJson, JSON)) .build(); try (Response response = okHttpClient.newCall(request).execute()) { String responseText = response.body() == null ? "" : response.body().string(); if (!response.isSuccessful()) { throw new IOException( "HTTP " + response.code() + ": " + responseText ); } // 使用 Gson/Jackson 解析 ApiResponse。 // 先判断 code,再读取 data;提示兼容 message 和 msg。 } ``` --- ## 11. TypeScript / H5 示例 ```ts type ApiResponse = { code: number; message?: string; msg?: string; data: T | null; }; type DetailRequest = { platform: 0 | 1 | 2; sourceId: string; // 必须是 string }; async function postPlaylet(path: string, body: unknown): Promise { const response = await fetch(`https://api.xueyuanpie.com${path}`, { method: "POST", headers: { "Content-Type": "application/json; charset=UTF-8", "Accept": "application/json" }, body: JSON.stringify(body) }); const result = await response.json() as ApiResponse; if (!response.ok) { throw new Error(result.message ?? result.msg ?? `HTTP ${response.status}`); } if (result.code !== 1000 || result.data == null) { throw new Error(result.message ?? result.msg ?? "业务请求失败"); } return result.data; } const detail = await postPlaylet( "/app/playlet/detail", { platform: 2, sourceId: "41000288558" } satisfies DetailRequest ); ``` 禁止写成 `sourceId: 41000288558`,否则对于长 ID 可能在 JSON 发送前就已经发生精度损失。 --- ## 12. 缓存、并发与性能建议 ### 12.1 可缓存内容 | 内容 | 建议缓存时间 | 说明 | |---|---|---| | 平台分类 | 10~30 分钟 | 切换平台时按平台分别缓存 | | 推荐/分类列表 | 1~5 分钟 | 下拉刷新可绕过缓存 | | 搜索结果 | 可不缓存 | 可仅保留当前会话 | | 短剧详情 | 1~5 分钟 | 连载短剧集数可能变化 | | 播放地址 | 不持久缓存 | 可能存在短时签名 | | 图片 | 遵循 CDN Header | 使用常规图片缓存库 | ### 12.2 并发控制 - 同一页面只保留最新一次搜索请求; - 切换平台、分类或关键词时取消旧分页请求; - 防止重复触发“加载更多”; - 播放按钮快速连点时,同一集只保留一个 `/play` 请求; - 不要在进入详情页时并发请求全部剧集的播放地址。 ### 12.3 日志脱敏 允许记录:接口路径、平台编码、分页参数、耗时、HTTP 状态、业务码。 谨慎记录:`sourceId`、`episodeId`,可仅保留末 4 位。 禁止记录:上游密钥、Cookie、设备会话参数、完整带签名播放 URL。 --- ## 13. APP 页面状态建议 每个列表页至少区分以下状态: 1. **首次加载**:骨架屏或加载动画; 2. **有数据**:正常展示列表; 3. **搜索为空**:显示“未找到相关短剧”; 4. **分类为空**:隐藏分类控件,不作为错误; 5. **网络错误**:显示重试按钮; 6. **服务不可用**:针对 `503` 显示“服务暂不可用,请稍后再试”; 7. **加载更多结束**:本页为空或少于 `size` 时停止; 8. **播放地址为空**:显示“暂时无法播放”,提供手动重试。 --- ## 14. 联调检查清单 ### 14.1 基础网络 - [ ] `https://api.xueyuanpie.com` TLS 证书校验通过; - [ ] 请求使用 HTTPS,不允许回退 HTTP; - [ ] Header 为 `Content-Type: application/json; charset=UTF-8`; - [ ] 中文关键词发送和响应均无乱码; - [ ] 能区分 HTTP 错误与业务 `code` 错误。 ### 14.2 三个平台 对 `platform=0/1/2` 分别验证: - [ ] 分类接口返回数组,允许为空; - [ ] 列表接口至少能解析一页或正常空数组; - [ ] 搜索接口可正确传递中文关键词; - [ ] 详情返回 `detail` 和 `episodes`; - [ ] 可选择一集并获得播放地址; - [ ] 响应枚举名分别能解析 `HONGGUO`、`QIMAO`、`HEMA`。 ### 14.3 边界条件 - [ ] `page=0` 时客户端不会崩溃; - [ ] `size=100` 时能接受服务端按 50 返回; - [ ] 空关键词在客户端被拦截; - [ ] 无效平台能展示服务端错误; - [ ] `sourceId` 超过 16 位仍保持原字符串; - [ ] `detail=null`、`episodes=[]`、`playUrl=null` 均可正常展示; - [ ] 主播放地址失败时会尝试备用地址; - [ ] `503` 时不会无限自动重试。 ### 14.4 上线前 - [ ] 实际短剧后端已监听 `127.0.0.1:18082`; - [ ] 公网 5 个接口返回真实业务 JSON,不再返回未部署 `503`; - [ ] 成功业务码和 `message/msg` 字段已按实际宿主确认; - [ ] 已配置生产鉴权、限流及日志脱敏; - [ ] 播放资源的使用方式符合上游授权和内容合规要求; - [ ] 证书自动续签已验证。 --- ## 15. 常见问题 FAQ ### Q1:为什么分类接口返回空数组? 部分平台,尤其红果,上游可能没有独立分类接口。空数组是正常成功结果,直接请求分页列表即可。 ### Q2:为什么 `total` 总是 `99999`? 这代表上游没有提供准确总数。它是占位值,不代表真的有 99999 条。请根据每页返回数量判断是否继续加载。 ### Q3:详情里已有 `playUrl`,还需要调用 `/play` 吗? 不需要。可以先直接播放;如果为空、过期或播放失败,再调用 `/play` 获取最新地址。 ### Q4:为什么同一个剧集播放地址过一段时间失效? 上游 CDN 地址可能带时间戳或签名。不要永久缓存,应在用户准备播放时重新获取。 ### Q5:能否把三个平台的结果混合在一个列表? 可以,但每条记录必须保留平台信息,并以 `platform + sourceId` 作为唯一标识。调用详情和播放时必须使用该条目的原平台。 ### Q6:HTTP 200 是否一定成功? 不一定。HTTP 200 只代表请求到达业务服务,还需要检查响应 `code`。 ### Q7:HTTP 503 是否是参数错误? 不是。当前部署中,503 通常表示短剧宿主服务尚未启动或暂不可用。参数错误应由业务响应提示。 ### Q8:接口是否支持 GET 或表单提交? 不支持。5 个接口统一使用 JSON POST。 --- ## 16. 版本与兼容性说明 - 新增可选字段时,客户端应忽略未知 JSON 字段; - 可空字段未来可能补全,客户端模型不应限制为非空; - 请求平台整数编码在 v1 内保持不变; - 响应平台枚举名固定为 `HONGGUO`、`QIMAO`、`HEMA`; - 若未来增加鉴权、API 版本前缀或新的成功码,应通过文档版本和发布通知同步; - 当前示例域名为正式 HTTPS 域名,但后端部署完成前接口可能返回明确的未部署 503。 --- ## 17. 联系与问题反馈建议 联调反馈至少提供以下信息,便于快速定位: ```text 发生时间(含时区): 接口路径: platform: HTTP 状态: 业务 code: 提示信息: 请求耗时: 是否可稳定复现: 脱敏后的 sourceId / episodeId: ``` 请勿在群聊、工单或截图中公开上游密钥、Cookie、设备会话、完整签名播放地址。