一同看 Android API 对接文档 版本:2026-04-24 · 后端:https://ytk-api.yitongcs.com

Android Retrofit + OkHttp Bearer Token HLS (ExoPlayer) Fingerprint.com

本文面向 Android 客户端开发,覆盖一同看所有对外 HTTP 接口的请求格式、响应契约、鉴权规则、错误处理、接入示例。服务端实现在 Cloudflare Workers,文档以"服务端真实行为"为准。配套:项目文档(架构总览)。

1. 通用约定

1.1 Base URL

环境Base URL
生产https://ytk-api.yitongcs.com
预览(CF Pages)https://ytk-ang.pages.dev(前端)+ 同一 API

所有路径以 /api/ 开头;少量 /admin/* 是运维 / 机器接口(客户端不用)。

1.2 请求头

Header必需说明
Content-Type: application/json; charset=utf-8写请求必需所有 POST/PATCH/PUT 都用 JSON
Authorization: Bearer <b64(userId:token)>需登录的接口必需token 从登录/注册响应拿;注销前本地持久化
User-Agent建议默认 Python/Java UA 会被 CF 以 1010 拦掉;用 ytk-android/<versionName>
X-Forwarded-For禁止CF 自动从连接拿 IP,客户端别加

1.3 Bearer Token 构造

登录 / 注册成功后,响应 data.token 是 32 字符 hex(内部称 md5 列)。构造 Authorization:

val raw = "${userId}:${token}"
val b64 = Base64.encodeToString(raw.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
val header = "Bearer $b64"
Android 特别注意: Base64.encodeToString 默认会加换行符,务必用 Base64.NO_WRAP

服务端用 atob(auth.slice(7)) 解出后取冒号后半段作为 token,再读 KV user:{token} 拿完整会话。

1.4 统一响应格式

所有 /api/* 响应都是:

{
  "code": "200",
  "message": "ok",
  "msg": "ok",
  "data": { ... }
}

1.5 HTTP / 业务 code 对照

HTTP典型 code场景
200"200"成功
200"201"业务错(登录失败、滑块错等),message 里有详情
400"400"参数不合法
401"401"未登录 / token 失效
403"403"权限不足(admin 端点被 IP 白名单拦)
404"404"资源不存在
405"405"方法不允许
422"422"内容被拒(评论 / 昵称 / 简介 AI 审核,data.reason 带原因)
500"500"服务端内部错误
504子请求 6s 超时

1.6 分页

请求参数page=<1-based>page_size=<N>(多数端点最大 50,history 是 100)

响应

{
  "items": [ ... ],
  "pagination": {
    "page": 1,
    "pageSize": 20,
    "total": 137,
    "totalPages": 7,
    "hasPrev": false,
    "hasNext": true
  }
}

传参固定用 page_sizepage(snake_case),响应里读 pagination.*(camelCase)。

1.7 时间戳

全部 Unix 秒(10 位 int),UTC 时区。不要期望带时区或毫秒。

1.8 CORS

生产白名单仅 ytk.yitongcs.com + *.ytk-ang.pages.devAndroid 原生 HTTP 客户端不走 CORS(CORS 是浏览器约束),所以随便用 Base URL 即可。

1.9 限流

Worker 层没有显式 QPS 限流,但间接节流:

1.10 重试策略(建议)

错误类型是否可重试
504 / 网络超时✅ 指数退避,最多 2 次
500⚠️ 谨慎,最多 1 次
401❌ 不重试,强制走重登流程
422❌ 不重试,给用户看 data.reason
code="201" 业务错❌ 不重试

2. 启动序列

推荐 App 冷启动 / 前台恢复时按以下顺序打,能并发就并发:

1. GET  /api/base/bootstrap              (公开) ← 站点名 / 首屏精选 / 导航
2. [登录态] GET /api/member/me           (需 token) ← 当前身份 / VIP / shareDomain
3. GET  /api/auth/test-domain?deviceId=  (仅 CN) ← 反封连通性探测
4. POST /api/auth/report-test-domain              ← 回报 pass/fail

2 和 3/4 完全并发;首屏先渲 1 的结果,登录态和分享域名后到。

2.1 首屏数据 GET /api/base/bootstrap

无参数,无鉴权。响应 data 字段:

{
  "siteName": "一同看",
  "tagline":  "最新热门资源",
  "imgUrl":   "https://pic.yulecdn.com",
  "imageUrl": "https://pic.yulecdn.com",
  "sections": [
    { "key": "gv",  "label": "GV",    "path": "/app/library/gv" },
    { "key": "mv",  "label": "电影",  "path": "/app/library/mv" },
    { "key": "tv",  "label": "电视剧","path": "/app/library/tv" },
    { "key": "pic", "label": "图库",  "path": "/app/library/pic" }
  ],
  "featured": [
    { "id": 209058, "section": "gv", "title": "…",
      "cover": "https://pic.yulecdn.com/…", "year": "",
      "has_hd": true, "durationSeconds": 571 }
    // 共 18 条
  ],
  "user": { "loggedIn": false, "username": null }
}

3. 鉴权与 Auth 模块

3.1 滑块挑战 GET/api/auth/slider-challenge

注册前必须先拿一个滑块 token,5 分钟内再拼进注册请求。

{
  "code": "200",
  "data": {
    "token": "uuid:expiresAt:signature",
    "expiresAt": 1776957168000
  }
}

3.2 注册 POST/api/auth/register

请求 body

{
  "username": "testuser01",
  "password": "abc12345",
  "confirmPassword": "abc12345",
  "verificationCode": "",
  "sliderToken": "uuid:1776957168000:sig…",
  "fingerprint": "<Fingerprint.com visitorId>",
  "fxid": 12345
}
字段类型必填说明
usernamestring3-30 字符,只允许 A-Za-z0-9_-
passwordstring6-32 字符
confirmPasswordstring必须等于 password(旧 PHP 字段名 checkpass 也被接受)
verificationCodestring已废弃,填空串
sliderTokenstring从 3.1 拿,1.5s 后用,5min 内
fingerprintstring✅(推荐)Fingerprint.com visitorId;没有会被视为"指纹重复"→ 拿不到 trial VIP
fxidnumber邀请人 uid,来自 URL ?fxid= 或本地;没有时传 0 或不传

响应(成功)

{
  "code": "200",
  "message": "Registration successful.",
  "data": {
    "token": "64 hex chars",
    "user": {
      "id": 54321,
      "username": "testuser01",
      "vipTime": 1776960768,
      "isVIP": true
    }
  }
}

响应(失败):业务错都是 HTTP 200 + code="201" + message 里有原因:

{ "code": "201", "message": "Username already exists.", "data": {} }
{ "code": "201", "message": "Please complete the slider verification.",
  "data": { "sliderReason": "expired" } }
{ "code": "201", "message": "This account or device is blocked.", "data": {} }

3.3 登录 POST/api/auth/login

{
  "username": "testuser01",
  "password": "abc12345",
  "fingerprint": "<visitorId>"
}

响应同注册 shape。

3.4 登出 POST/api/auth/logout

Body 空;需要 Authorization。服务端清 KV + MySQL md5 作废;客户端也要清本地 token 回登录页。

3.5 推广来源登记 POST/api/auth/referral

Android 一般不用。响应的 Set-Cookie Android HTTP 客户端默认不处理。推荐:直接在 register body 里带 fxid

3.6 滑块 UI 协议

4. 会员资料

全部需要 Authorization。未登录 401 + code="401"

4.1 我的信息 GET/api/member/me

{
  "code": "200",
  "data": {
    "id": 54321,
    "username": "testuser01",
    "nickname": "测试昵称",
    "headimg": "https://ytk-api.yitongcs.com/cdn/avatars/54321.jpg",
    "vipTime": 1776960768,
    "isVIP": true,
    "email": "xxx@example.com",
    "mobile": "",
    "shareDomain": "https://gvm1.fun/?fxid=54321",
    "shareStats": { "share": 5, "sharePending": 2, "shareRewarded": 3 }
  }
}

4.2 资料详情 GET/api/member/profile

响应 data.profile

{
  "id": 54321,
  "username": "testuser01",
  "nickname": "测试昵称",
  "headimg": "https://.../avatars/54321.jpg",
  "birthday": 946684800,
  "sex": 1,
  "intro": "这是个人简介",
  "vipTime": 1776960768,
  "isVIP": true,
  "email": "xxx@example.com",
  "mobile": "",
  "mobileCountryCode": ""
}

4.3 修改资料 PATCH/api/member/profile

字段全部可选,只传要改的

{ "nickname": "新昵称", "sex": 1, "birthday": 946684800, "intro": "新简介" }
字段约束
nickname1-20 字符,不含 <>"
sex0 / 1 / 2 / 3
birthdayUnix 秒;-2208988800now0 清空
intro最多 255 字符

响应(审核拒):HTTP 422 + code="422"

{
  "code": "422",
  "message": "包含脏话或辱骂词汇",
  "data": { "reasonCode": "NICKNAME_REJECTED", "reason": "包含脏话或辱骂词汇" }
}

reasonCode 可能是 NICKNAME_REJECTEDINTRO_REJECTED。服务端会给该用户发一条 type=system 站内通知。

4.4 改密码 PATCH/api/member/password

{ "oldPassword": "abc12345", "newPassword": "xyz98765" }

约束:密码 8-32 字符,必须同时含字母和数字,不能等于老密码。

成功响应

{ "code": "200", "data": { "token": "新的 32 char hex" } }
重要: 改密后服务端会生成新 token(旧 token 立即作废),客户端必须用 data.token 覆盖本地 token。

错误 codeFIELDS_MISSING / OLD_PASSWORD_WRONG(HTTP 409)/ PASSWORD_WEAK / PASSWORD_SAME_AS_OLD

4.5 发邮箱验证码 POST/api/member/email/send-code

// Request
{ "email": "xxx@example.com" }
// Response
{ "code": "200", "data": { "cooldownSeconds": 60 } }

4.6 验证邮箱 POST/api/member/email/verify

// Request
{ "email": "xxx@example.com", "code": "123456" }
// Response
{ "code": "200", "data": { "verified": true } }

4.7 上传头像 POST/api/member/avatar

请求Content-Type: multipart/form-data,字段名 file,最大 5 MB,只接 JPEG/PNG/WebP。

{
  "code": "200",
  "data": { "url": "https://ytk-api.yitongcs.com/cdn/avatars/54321-<ts>.jpg" }
}

4.8 绑定用户中心 POST/api/member/bind-uc

// Request
{ "username": "uc-user", "password": "xxxxx" }
// Response
{ "code": "200", "data": { "ucUserId": 123456 } }

5. VIP / 流量 / 订单

5.1 VIP 状态 GET/api/member/vip

{
  "code": "200",
  "data": { "vipTime": 1776960768, "isVIP": true, "remainingSeconds": 3561 }
}

5.2 流量 GET/api/member/flow

{
  "code": "200",
  "data": { "flow": 1073741824, "flowText": "1GB" }
}

flow 单位字节;flowText 服务端格式化好了直接显示。

5.3 创建订单 POST/api/member/order/create

// Request
{ "productId": 1, "payType": "wx", "returnUrl": "ytkapp://pay/return" }
// Response
{
  "code": "200",
  "data": {
    "orderId": "YTK202604241234567",
    "payUrl":  "https://pay.gvpay.vip/xxx",
    "amount":   3900,
    "currency": "CNY"
  }
}

6. 邀请与裂变

6.1 邀请战绩 GET/api/member/referrals?page=1&page_size=20

{
  "shareDomain": "https://gvm1.fun/?fxid=54321",
  "share": 5,
  "sharePending": 2,
  "shareRewarded": 3,
  "items": [
    { "id": 9001, "username": "t***r02",
      "time": 1776900000, "good": 1, "is_dood": 1 }
  ],
  "pagination": { "page": 1, "pageSize": 20, "total": 5, ... }
}

6.2 邀请统计 GET/api/member/invite/stats

只返回三个计数,比 /referrals 轻量。

6.3 邀请列表 GET/api/member/invite/list

只返回列表,不带 shareDomain。

7. 内容浏览

不需要鉴权(除了 /play/download)。

7.1 GV 列表 GET/api/content/gv

参数取值说明
page>=1默认 1
page_size1..100默认 30
typeall / china / korea / japan / southeast / europe默认 all
sortlatest / favorites / random默认 latest
seed1 .. 2^31-1sort=random 用,同 seed 同排列,翻页稳定

响应 data

{
  "section": "gv",
  "title":   "GV Library",
  "type":    "all",
  "filters": [
    { "value": "all",   "label": "全部", "count": 89786 },
    { "value": "china", "label": "中国", "count": 12340 }
    // ...
  ],
  "items": [
    {
      "id": 209011, "section": "gv", "title": "…",
      "cover": "https://pic.yulecdn.com/…",
      "year": "", "has_hd": true,
      "durationSeconds": 1213, "viewCount": 5243
    }
  ],
  "pagination": { ... }
}

缓存sort=latest|favorites 有 60s edge + 600s KV 缓存;sort=random 跳过 edge cache,但同 seed 仍稳定。

7.2 GV 详情 GET/api/content/gv/:id

{
  "id": 109114, "section": "gv", "title": "…",
  "cover": "https://…", "year": "", "has_hd": true,
  "playUrls": {
    "p480":  "https://ytk-api.yitongcs.com/api/media/…",
    "p720":  "https://…",
    "p1080": "https://…"
  },
  "durationSeconds": 1516,
  "viewCount": 5243,
  "legacyPlayerUrl": "/play-109114.html?embed=1"
}
playUrls.p*签名 URL,有效期约 5 分钟,获取后立即使用。

7.3 MV / TV / 图集列表

与 GV 列表同参数结构。MV 没有 durationSeconds / has_hd(老数据未存)。

TV 详情附带剧集

{
  "id": 5, "section": "tv", "title": "剧集名",
  "episodes": [
    { "id": 101, "name": "第1集", "playUrl": "/data/…/index.m3u8" },
    { "id": 102, "name": "第2集", "playUrl": "…" }
  ]
}

播放某一集:GET /api/content/tv/:tvId/play?episode_id=101

图集详情带 images: string[] 完整 URL 数组。

7.4 全站搜索 GET/api/content/search?wd=&section=gv&page=1&page_size=30

{
  "keyword": "abc",
  "items":   [ /* 与该 section item 同形状 */ ],
  "pagination": { ... }
}

缓存 3h(10800s KV)

8. 媒体播放与下载

8.1 GV 播放 GET/api/content/gv/:id/play

需要 VIP。未登录 401;登录但非 VIP 403。

{
  "code": "200",
  "data": {
    "url":        "https://ytk-api.yitongcs.com/api/media/…",
    "defaultUrl": "https://…",
    "qualities": [
      { "label": "流畅", "url": "https://…" },
      { "label": "标清", "url": "https://…" },
      { "label": "高清", "url": "https://…" }
    ],
    "sizeBytes": 104857600,
    "viewCount": 5243
  }
}

8.2 MV / TV 播放

同 shape(MV / TV 免 VIP):GET /api/content/mv/:id/playGET /api/content/tv/:id/play?episode_id=<ep>

8.3 GV 下载 GET/api/content/gv/:id/download

需要 VIP + 流量余额。

{
  "code": "200",
  "data": {
    "url":   "https://down.gv.live/xxx?token=...",
    "title": "视频文件名",
    "format": "MP4",
    "durationSeconds": 1516,
    "requiredFlow":     524288000,
    "requiredFlowText": "500MB",
    "remainingFlow":     1073741824,
    "remainingFlowText": "1GB",
    "alreadyGranted":    false
  }
}

流量不足

{
  "code": "400",
  "message": "Insufficient remaining flow.",
  "data": {
    "requiredFlow":    524288000, "requiredFlowText": "500MB",
    "remainingFlow":   100000,    "remainingFlowText": "100KB"
  }
}

8.4 媒体回源 /api/media/<signed_path>

这是播放地址本身,客户端不要手动构造/play 返回的 qualities[].url 已经是带签名的完整 URL,直接交 ExoPlayer。

9. 评论

9.1 列表 GET/api/comments?section=gv&id=<contentId>&page=1&page_size=20

无需鉴权(登录用户多一个 liked 标记)。

{
  "items": [
    {
      "id": 1234, "section": "gv", "content_id": 109114,
      "user_id": 54321, "username": "t***r01",
      "text": "评论内容", "created_at": 1776900000,
      "likes": 5, "parent_id": null, "liked": false,
      "replies": [
        { "id": 1235, "parent_id": 1234, "username": "u***r02",
          "text": "回复", "created_at": 1776900100, "likes": 1, "liked": false }
      ]
    }
  ],
  "total": 137, "page": 1, "page_size": 20
}

9.2 发表评论 POST/api/comments

需要鉴权。

{
  "section": "gv",
  "id": 109114,
  "text": "这个视频不错",
  "parent_id": null
}
字段名是 id不是 contentId(和 favorites/check 不同,历史遗留)。

服务端 sanitize 后入库,前端收到的 data.text 可能短过你发的(去了 <script> 等)。

审核拒响应(HTTP 422)

{
  "code": "422",
  "message": "content rejected",
  "data": { "reasonCode": "rejected", "reason": "包含脏话或辱骂词汇" }
}

其它错 code="400" + data.reasonCodeempty / too_long / invalid_parent / shard_missing

9.3 切换点赞 POST/api/comments/:id/like

{ "code": "200", "data": { "likes": 6, "liked": true } }

9.4 删除评论 DELETE/api/comments/:id

只能删自己的;顶层评论会级联删 replies。

{ "code": "200", "data": { "deleted": true } }

10. 收藏

10.1 列表 GET/api/member/favorites?section=gv&page=1&page_size=20

section 可选。响应 data.items

[{
  "uid": 54321, "section": "gv", "content_id": 109114,
  "title": "…", "cover": "https://…", "year": "",
  "durationSeconds": 1516, "added_at": 1776800000
}]

10.2 是否已收藏 GET/api/member/favorites/check?section=gv&contentId=109114

{ "code": "200", "data": { "favorited": true } }
这个端点参数名是 contentId 驼峰(和 /api/commentsid 不一致,历史遗留)。

10.3 添加 / 取消 POST/api/member/favorites

{
  "section": "gv",
  "id": 109114,
  "action": "add",
  "title": "…",          // add 时必填
  "cover": "https://…",   // add 时必填
  "year": "",
  "durationSeconds": 1516
}

action = "add""remove"

10.4 RESTful 删除 DELETE/api/member/favorites/:section/:contentId

语义等于 {action:"remove"}

11. 观看历史

11.1 上报心跳 POST/api/member/history

{
  "section":         "gv",
  "contentId":       109114,
  "title":           "视频标题",
  "cover":           "https://…",
  "durationSeconds": 1516,        // 可选,服务端能补
  "watchedSeconds":  45,
  "progress":        3,            // 0-100 百分比
  "episodeId":       0,            // TV 必填对应集数 id
  "episodeLabel":    ""            // TV 可选文案
}

调用时机建议

{ "code": "200", "data": { "id": "gv-109114-0" } }

id = ${section}-${contentId}-${episodeId} 合成 key,用于后续删除。

11.2 历史列表 GET/api/member/history?section=gv&page=1&page_size=20

{
  "items": [
    { "id": "gv-109114-0", "section": "gv", "contentId": 109114,
      "episodeId": 0, "episodeLabel": "",
      "title": "…", "cover": "https://…",
      "progress": 67, "durationSeconds": 1516, "watchedSeconds": 1015,
      "watchedAt": 1776900000 }
  ],
  "stats": {
    "totalAll":      37,
    "sectionCounts": { "gv": 15, "mv": 10, "tv": 10, "pic": 2 },
    "todayCount":    5
  },
  "pagination": { ... }
}

11.3 删除某条 DELETE/api/member/history/:id

:id 用 11.1 返回的合成 key,如 gv-109114-0

11.4 清空全部 DELETE/api/member/history

Body 空;清除所有 section 全部记录。

11.5 查归档(>90 天) GET/api/member/history/archive?from=&to=&page=&page_size=

from / to Unix 秒,最大区间 366 天读冷存储 R2,可能慢 1-3 秒,UI 加 loading 提示。

12. 点赞 / 踩

投票 POST/api/content/:section/:id/vote

// Request
{ "vote": 1 }   // 1=点赞 / -1=踩 / 0=取消
// Response
{
  "code": "200",
  "data": { "likes": 120, "dislikes": 5, "userVote": 1 }
}

不需鉴权也能投;登录用户状态持久化更好。

13. 站内信

13.1 列表 GET/api/member/notifications?page=1&page_size=20

{
  "items": [
    {
      "id": 1000000042,
      "type": "system",
      "title": "昵称未通过审核",
      "content": "你提交的昵称内容未通过审核,原因:…",
      "meta": { "field": "nickname", "reason": "…" },
      "createdAt": 1776960000,
      "expiresAt": null,
      "read": false,
      "isBroadcast": true
    }
  ],
  "total": 5, "unread": 3,
  "pagination": { ... }
}

13.2 未读计数 GET/api/member/notifications/unread-count

{ "code": "200", "data": { "count": 3 } }

高频(顶部红点),建议客户端 1 分钟刷一次。

13.3 标记已读

14. 意见反馈

14.1 提交 POST/api/member/feedback

{
  "content":  "反馈内容",
  "contact":  "微信/邮箱(可选)",
  "category": "bug"     // bug / suggestion / complaint / other
}

限流:同用户每天最多 5 条(fb:daily:{uid}:{yyyymmdd})。

14.2 我的反馈列表 GET/api/member/feedback/list?page=&page_size=

{
  "items": [
    { "id": 42, "content": "…", "category": "bug",
      "status": "open",      // open / replying / closed
      "reply": null, "replied_at": null,
      "created_at": 1776900000 }
  ],
  "pagination": { ... }
}

15.1 热词 GET/api/search/trending?limit=20

15.2 最新搜索 GET/api/search/recent?limit=20

{
  "code": "200",
  "data": {
    "items": [
      { "query": "写真", "count": 22, "last_used": 1776853463 },
      { "query": "帅哥", "count": 22, "last_used": 1776792354 }
    ]
  }
}

trending 按 count DESC;recent 按 last_used DESC。

15.3 记录查询 POST/api/search/log

{ "query": "搜索词" }

不需鉴权,纯匿名统计。用户按下回车 / 点搜索按钮时调。

16. 分享域名检测

反封锁基础设施,大多数客户端不需要实现。

16.1 拿测试域名 GET/api/auth/test-domain?deviceId=<uuid>

非 CN 大陆 + 未识别运营商返回 code="400" + "IP not eligible"。CN + 运营商识别成功返回 {domain: "gvm1.fun"}

16.2 回报结果 POST/api/auth/report-test-domain

{ "deviceId": "<uuid>", "status": "pass", "domain": "gvm1.fun" }

status: pass / fail / timeout

17. 错误码索引

17.1 业务 code 对照

codeHTTP含义
"200"200成功
"201"200业务错(登录失败 / 用户名占用 / 滑块错 / 黑名单等)
"400"400参数不合法 / 限流 / 余额不足
"401"401未登录或 token 失效
"403"403权限不足
"404"404资源不存在
"405"405方法不允许
"422"422内容被 AI 审核拒(data.reason 带原因)
"500"500服务端内部错误

17.2 资料更新 reasonCode

17.3 密码修改 code

17.4 评论 reasonCode

18. Android 集成最佳实践

18.1 Retrofit + OkHttp 样板

// build.gradle.kts
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
object YtkApi {
    private const val BASE = "https://ytk-api.yitongcs.com/"

    private val authInterceptor = Interceptor { chain ->
        val req = chain.request()
        val token = TokenStore.current   // 已 b64
        val builder = req.newBuilder()
            .header("User-Agent", "ytk-android/${BuildConfig.VERSION_NAME}")
        if (token != null) builder.header("Authorization", "Bearer $token")
        chain.proceed(builder.build())
    }

    private val client = OkHttpClient.Builder()
        .addInterceptor(authInterceptor)
        .addInterceptor(HttpLoggingInterceptor().apply {
            level = if (BuildConfig.DEBUG) BODY else NONE
        })
        .connectTimeout(10, TimeUnit.SECONDS)
        .readTimeout(15, TimeUnit.SECONDS)
        .build()

    val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl(BASE)
        .client(client)
        .addConverterFactory(MoshiConverterFactory.create(
            Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
        ))
        .build()
}

18.2 统一响应包装

@JsonClass(generateAdapter = true)
data class Envelope<T>(
    val code: String, val message: String,
    val msg:  String, val data: T?
)

fun <T> Envelope<T>.unwrap(): Result<T> = when (code) {
    "200" -> if (data != null) Result.success(data)
             else Result.failure(ApiError(code, message))
    else  -> Result.failure(ApiError(code, message))
}

class ApiError(val code: String, msg: String) : RuntimeException(msg)

18.3 Token 存储(EncryptedSharedPreferences)

object TokenStore {
    private val prefs by lazy {
        val key = MasterKey.Builder(App.ctx)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build()
        EncryptedSharedPreferences.create(
            App.ctx, "ytk_secure", key,
            EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
        )
    }

    var uid: Int
        get() = prefs.getInt("uid", 0)
        set(v) = prefs.edit().putInt("uid", v).apply()

    var rawToken: String?
        get() = prefs.getString("token", null)
        set(v) = prefs.edit().putString("token", v).apply()

    val current: String? get() {
        val u = uid; val t = rawToken
        if (u <= 0 || t == null) return null
        val raw = "$u:$t".toByteArray(Charsets.UTF_8)
        return Base64.encodeToString(raw, Base64.NO_WRAP)
    }

    fun set(uid: Int, token: String) { this.uid = uid; this.rawToken = token }
    fun clear() { prefs.edit().clear().apply() }
}

18.4 Fingerprint.com 集成

implementation("com.fingerprint:fingerprint-pro-android:2.x")

val fp = FingerprintAndroidFactory.createInstance(applicationContext,
    Configuration(apiKey = "<公开 key,前端可暴露>"))
fp.getVisitorId({ visitorId ->
    FxStore.visitorId = visitorId    // 注册时带进 body.fingerprint
}, { error ->
    FxStore.visitorId = ""           // 拿不到指纹 → 按"重复设备"处理
})

18.5 fxid 归因(Deep Link)

<!-- AndroidManifest.xml -->
<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" android:host="gvm1.fun" android:pathPattern="/.*" />
    <data android:scheme="https" android:host="gvm2.fun" android:pathPattern="/.*" />
    <!-- 分享域名可能动态下发 -->
</intent-filter>
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val fxid = intent?.data?.getQueryParameter("fxid")?.toIntOrNull() ?: 0
    if (fxid > 0) FxStore.fxid = fxid   // 5h 有效,注册时取
}

18.6 Token 失效处理

val authGuard = Interceptor { chain ->
    val resp = chain.proceed(chain.request())
    val peek = resp.peekBody(Long.MAX_VALUE).string()
    try {
        val json = JSONObject(peek)
        if (json.optString("code") == "401") {
            TokenStore.clear()
            App.eventBus.post(TokenExpired)
        }
    } catch (_: Throwable) { /* 非 JSON 响应忽略 */ }
    resp
}

18.7 图片加载

Glide.with(this)
    .load(video.cover)
    .placeholder(R.drawable.ph_cover)
    .into(binding.imgCover)

18.8 播放器接入(ExoPlayer)

val playResult = YtkApi.retrofit.create(Gv::class.java).play(id).unwrap().getOrThrow()
val mediaSource = HlsMediaSource.Factory(DefaultHttpDataSource.Factory())
    .createMediaSource(MediaItem.fromUri(playResult.url))
player.setMediaSource(mediaSource)
player.prepare()
player.playWhenReady = true
签名过期: 播放器抛 HTTP 403,重调 /play 拿新 URL 即可。本地 URL 缓存不超过 3 分钟。

18.9 通用错误提示映射

fun ApiError.userMessage(ctx: Context): String = when (code) {
    "401" -> ctx.getString(R.string.err_need_login)
    "403" -> ctx.getString(R.string.err_no_permission)
    "404" -> ctx.getString(R.string.err_not_found)
    "422" -> message   // AI 审核 reason,直接展示
    "500" -> ctx.getString(R.string.err_server)
    else  -> message.ifBlank { ctx.getString(R.string.err_unknown) }
}

19. 端点速览

SectionMethodPath鉴权说明
BaseGET/api/base/bootstrap🔓站点配置 + 首屏精选
AuthGET/api/auth/slider-challenge🔓发滑块 token
AuthPOST/api/auth/register🔓注册
AuthPOST/api/auth/login🔓登录
AuthPOST/api/auth/logout🔒登出
AuthPOST/api/auth/referral🔓记 fxid cookie(Android 可跳过)
AuthGET/api/auth/test-domain🔓分享域连通性测试
AuthPOST/api/auth/report-test-domain🔓回报
MemberGET/api/member/me🔒身份 + VIP + shareDomain
MemberGET/PATCH/api/member/profile🔒查/改资料
MemberPATCH/api/member/password🔒改密码
MemberPOST/api/member/avatar🔒上传头像
MemberPOST/api/member/email/send-code🔒发邮箱验证码
MemberPOST/api/member/email/verify🔒验证邮箱
MemberPOST/api/member/bind-uc🔒绑定 UC
VIPGET/api/member/vip🔒VIP 详情
VIPGET/api/member/flow🔒流量
VIPPOST/api/member/order/create🔒下单
ShareGET/api/member/referrals🔒战绩
ShareGET/api/member/invite/stats🔒邀请计数
ShareGET/api/member/invite/list🔒邀请列表
ContentGET/api/content/gv🔓GV 列表
ContentGET/api/content/gv/:id🔓GV 详情
ContentGET/api/content/gv/:id/play🔒 VIPGV 播放 URL
ContentGET/api/content/gv/:id/download🔒 VIPGV 下载 URL
ContentGET/api/content/mv|tv|pic[/:id][/play]🔓 / 🔒各 section
ContentGET/api/content/search🔓全文搜索
ContentPOST/api/content/:section/:id/vote🔓点赞 / 踩
CommentGET/api/comments🔓评论列表
CommentPOST/api/comments🔒发评论(AI 审核)
CommentPOST/api/comments/:id/like🔒切换点赞
CommentDELETE/api/comments/:id🔒删评论
FavoriteGET/POST/api/member/favorites🔒收藏
FavoriteGET/api/member/favorites/check🔒是否已收藏
FavoriteDELETE/api/member/favorites/:section/:id🔒取消收藏
HistoryGET/POST/DELETE/api/member/history🔒历史
HistoryDELETE/api/member/history/:id🔒删单条
HistoryGET/api/member/history/archive🔒冷归档查询
NotifyGET/api/member/notifications🔒站内信列表
NotifyGET/api/member/notifications/unread-count🔒未读计数
NotifyPOST/api/member/notifications/:id/read🔒标记已读
NotifyPOST/api/member/notifications/read-all🔒全部已读
FeedbackPOST/api/member/feedback🔒提交反馈(限流 5/日)
FeedbackGET/api/member/feedback/list🔒我的反馈
SearchGET/api/search/trending🔓热词
SearchGET/api/search/recent🔓最新搜索
SearchPOST/api/search/log🔓记录查询

🔓 公开 · 🔒 需 Bearer Token · 🔒 VIP 需登录且 isVIP=true