Files
maidong-ktv/app/src/main/java/com/local/ktv/SongApiClient.kt
T
zijunchiang d01269e917 取址改为官方源优先+自建源兜底,只有官方源成功才上报(V1.6.2/45,未发布)
需求一:流程反转 + 上报绑定官方源成功
- 默认 sourceHosts 反转为 ["http://zk.ktv1.top:6677", "https://mdmuse.kasugano.cn"];
  SourceHosts.DEFAULT / SongUrlResolver.DEFAULT_HOSTS(收敛为引用前者)/ ktv_api.js
  CONFIG.HOSTS+HOST 三处一致。
- 取址结果改为 SourcedSongUrl(url, sourceHost)(**显式来源标志**,不靠 URL 猜):
  新增 SongSourceFlow(回落链 firstAvailable / 上报判定 shouldReportToSelfHost /
  全失败信号);JS 侧新增 android.onSongSource(requestId, host) 回传命中的源。
- 汇合点 SongApiClient.getSongDownloadUrl 只在"地址来自官方源"时才 POST /push_download;
  自建源自己给的地址与 DB 回落都不上报。
- 推送默认目标改为"从歌曲源列表里找第一个自建源"(自建源不再排第一,旧写法会直接关掉推送)。
- 1.6.1 落盘的旧默认顺序(自建源优先)在 KtvStore.load() 里一次性迁移;自定义列表不动。
- 业务码 404(自建源没有这首歌)仍只当"该源没地址",不置 sawForbidden、不进冷却、不污染受限统计。

需求二:6 条隐患
1) 设置页改源对 JS 立即生效:KtvJsBridge.applySourceSettingsNow 重新注入 KtvApiConfig.*
   并把已构造实例的 window.api.sourceOrder 重置为新列表;SongApiClient.applySourceSettings 接线。
2) JS 路径识别业务码 403:新增 KtvJsBridge.riskForbidden(status, body)(纯函数,手写判定
   避免 org.json 在单测里是空壳),HTTP 403 与 HTTP 200+{"code":403} 都算风控,与原生 sawForbidden 同口径。
3) isTrustedDownloadUrl 移除广告域名 ac16.vip 白名单,并改为黑名单优先(不再依赖调用顺序)。
4) MainActivity.download(song)(第五条地址消费点)补 AdVideoFilter.isTrustedSongUrl 闸门,
   不可信按"源失效"处理(Eac19WindowGuard.noteAllSourcesFailed),不再静默下载。
5) SongApiClient.fetchFromDb 不再静默吞异常:曲库打不开/查询异常各记一条 MiliLog.localWarn(含编号与异常)。
6) 删除死代码 streamDownloadAndPlay(全仓 grep 无调用点,且内部取址不过最终闸门)。

需求三:死代码/漂移清理(均先全仓搜索确认无引用)
- AdVideoFilter.replacementUrl 无任何写入点(恒为 null)→ 删除,resolve() 只保留"屏蔽"语义。
- ktv_api.js CONFIG.VER/VN 由"从不生效的死值"改为实际使用值 2.0 / 4.1.3.03281430。
- CONFIG.HOST 改为被 getToken 真正读取(不再"注入但从未被读")。
- KTV_BRIDGE_API 14 → 15(JS 两处 + KtvJsBridge 两处校验);AGENTS.md / README.md / README.en.md
  的陈旧标记统一到 15。
- do.php 的 limit 与 i.php 的 retry/ordertype/end_date/heart_time 确认无消费方 → 不新增解析(保持现状)。

需求四:测试与打包
- 新增 SourceOrderConsistencyTest / SongSourceFlowTest / JsBridgeGateTest / DownloadEntryGateTest,
  更新 SourceHostsTest / AdVideoFilterTest;app/build.gradle 打开 unitTests.returnDefaultValues。
- testReleaseUnitTest --rerun-tasks --offline:12 个测试类 / 87 例全过。
- assembleRelease --offline 通过(lintVital NewApi 0 条);版本 1.6.1/44 → 1.6.2/45。

不发布:未推 Gitea/Gitee、未动 Release/tag。
2026-10-14 03:45:07 +08:00

268 lines
13 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.local.ktv
import android.content.Context
import android.os.Looper
import android.util.Log
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* 歌曲 API 客户端 —— JS 热更新桥接
* =================================
* - 通过 KtvJsBridge 调用 Gitea 上的 JS 脚本
* - API 变更时只需更新 Gitea JS 文件,App 无需发版
* - 首次播放时实时获取 URL,不存在过期问题
*/
object SongApiClient {
private const val TAG = "SongApiClient"
private var bridge: KtvJsBridge? = null
@Volatile private var ready = false
@JvmStatic
fun init(context: Context) {
if (bridge != null) return
bridge = KtvJsBridge(context)
// 原生兜底取址通道:复用桥的「干净请求」裸 Socket(与 JS 同一条路,风控表现一致),
// 但**不依赖 WebView 是否可用** —— 老盒子上 WebView 不可用时唯一能取到地址的路径。
// 第二参是自建源的 App 令牌(官方源传 null,请求形态与改造前逐字节一致)。
SongUrlResolver.install { url, appToken -> bridge?.rawCleanGet(url, appToken) }
// api.php 取配置走 POST(原版形态);桥不可用时为 null,此时跳过配置拉取。
SongUrlResolver.installPost { url, form -> bridge?.rawCleanPost(url, form) }
// App 令牌(自建源 `GET /token`)也走同一条干净 GET:与取址共用缓存与"该源不支持"记忆。
AppTokenClient.install { url -> bridge?.rawCleanGet(url, null, AppToken.TIMEOUT_MS) }
bridge?.init { ok ->
ready = ok
// 失败原因由 KtvJsBridge.markUnavailable() 统一上报(只记一次),此处不再重复记日志。
Log.i(TAG, "JS Bridge init: $ok")
}
}
/** 兼容旧接口 */
@JvmStatic
fun init(sn: String, mac: String) {
// JS 脚本自己生成 MAC/SN
}
@JvmStatic
fun getAuthToken(): String = ""
@JvmStatic
fun getSongDownloadUrl(musicno: String?): String? =
musicno?.takeIf(String::isNotBlank)?.let { getSongDownloadUrl(it, "720", false) }
@JvmStatic
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? =
getSongDownloadUrl(musicno, resolution, h265, "")
/**
* 取址(**四条路径唯一的汇合点**)+ 「App 点歌推送」切入点。
*
* 为什么把推送挂在这里(而不是 JS/原生各自的成功分支):能拿到下载地址的路径有四条
* (JS 桥 / 原生兜底 / DB 回退 / 主线程 DB 兜底),只有本方法能保证"**只要用户真的拿到了
* 一条可信下载地址,就恰好推送一次**";挂在别处必然漏掉某条路径(历史上广告闸门就是这么漏的)。
*
* ⚠️ **只有"地址来自官方源"才推送**(2026-10-14 新流程):官方源成功 → 把该下载地址
* 上报自建服务器(`POST /push_download`,让它留存并入库这首);地址来自**自建源**时不推
* (服务器本来就有这首),DB 兜底同样不推。判定用的是取址实现**显式返回**的来源标志
* ([SourcedSongUrl.sourceHost],见 [SongSourceFlow.shouldReportToSelfHost]),
* **不靠 URL 字符串猜、也不读可变全局**。
*
* 推送本身是 fire-and-forget(见 [PushDownloadClient]):默认关闭、后台线程、8 秒超时、
* 失败只写本地日志,**不阻塞播放/下载,也不改返回值**。
*
* @param songName 歌名(可选,仅用于推送日志与服务器核对,不入库)
*/
@JvmStatic
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean, songName: String): String? {
val resolved = resolveSongDownloadUrl(musicno, resolution, h265)
val url = resolved?.url
if (!url.isNullOrEmpty() && musicno.isNotBlank() &&
SongSourceFlow.shouldReportToSelfHost(resolved)
) {
runCatching { PushDownloadClient.offer(musicno.trim(), url, songName) }
.onFailure { Log.w(TAG, "push download skipped", it) }
}
return url
}
/**
* 真正的取址实现(推送不参与其逻辑,见上面的入口)。
*
* 返回 [SourcedSongUrl](= 地址 + **产出它的源**)而不是裸字符串:汇合点据此判定
* "是否上报自建服务器"(见 [getSongDownloadUrl])。回落链
* JS 主路径 → 原生兜底 → DB 回退由 [SongSourceFlow.firstAvailable] 表达。
*/
private fun resolveSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): SourcedSongUrl? {
// 空编号必须提前挡掉(推送的 `tid` 也要求非空),否则会拿空编号去扫一遍上游源
if (musicno.isBlank()) return null
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
// ⚠️ 主线程**绝不能**走网络路径:原生兜底最多 7 个源 × 8 秒超时,会把 UI 线程
// 卡死到 ANR(1.4.7 曾把这里改成先 resolveNatively 再回退,属于隐患)。
// 主线程只做本地的 DB 兜底,网络取址一律留给后台线程。
if (Looper.myLooper() == Looper.getMainLooper()) {
Log.w(TAG, "Synchronous request on main thread; using DB fallback only")
return fetchFromDb(musicno)
}
val bridge = bridge
?: return SongSourceFlow.firstAvailable(
{ resolveNatively(musicno, resolution, h265) },
{ fetchFromDb(musicno) },
)
// 已判定本机 WebView 不可用 → 不再每次白等 15 秒,直接用原生通道。
// (极米 H1S 实测:每次都等满 15 秒,远程日志里每首歌前都有一条「15s 未就绪」。)
if (bridge.isUnavailable()) {
return SongSourceFlow.firstAvailable(
{ resolveNatively(musicno, resolution, h265) },
{ fetchFromDb(musicno) },
)
}
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
// 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。
val deadline = System.currentTimeMillis() + 15_000L
while (!bridge.isReady() && System.currentTimeMillis() < deadline && !bridge.isDestroyed()) {
try { Thread.sleep(100) } catch (_: InterruptedException) {
Thread.currentThread().interrupt()
break
}
}
if (!bridge.isReady()) {
// ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是
// 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器,
// 并把"不可用"记成持久状态(只上报一次),后续点歌不再等这 15 秒。
Log.w(TAG, "Bridge not ready after wait, native resolver")
bridge.markUnavailable("15s 未就绪")
return SongSourceFlow.firstAvailable(
{ resolveNatively(musicno, resolution, h265) },
{ fetchFromDb(musicno) },
)
}
val result = AtomicReference<SourcedSongUrl?>()
val latch = CountDownLatch(1)
bridge.getSongUrl(musicno, resolution, h265) { url, sourceHost ->
// JS 同时回传"本次命中的源"(第二参)→ 构造**显式来源标志**。
result.set(url?.takeIf { it.isNotEmpty() }?.let { SourcedSongUrl(it, sourceHost.orEmpty()) })
latch.countDown()
}
// JS 需要覆盖主/备节点和最多 3 次设备重试;略长于桥内 180 秒总超时。
try {
latch.await(185, TimeUnit.SECONDS)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
// 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会,
// 避免 JS 侧偶发失败直接把第一首歌判死。
val resolved = SongSourceFlow.firstAvailable(
{ result.get() },
{ resolveNatively(musicno, resolution, h265) },
{ fetchFromDb(musicno) },
)
if (resolved != null) {
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
Eac19WindowGuard.noteUrlOk()
}
return resolved
}
/**
* 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。
* 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。
*/
private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): SourcedSongUrl? =
runCatching { SongUrlResolver.resolveSourced(musicno, resolution, h265) }
.onFailure { Log.w(TAG, "native resolver failed", it) }
.getOrNull()
/**
* DB `cloud_url` 回落:必须通过 demo 检测与地址闸门才能使用。
*
* 返回的 [SourcedSongUrl.sourceHost] 是**空串** —— DB 里的地址既不是官方源也不是自建源
* 现取的,因此 [SourcedSongUrl.fromOfficialSource] 为 false → **不上报**
* (符合"只有官方源成功才上报")。
*
* ⚠️ 这里**不再静默吞异常**(隐患 5):DB 打不开 / 查询抛异常与"DB 里没有 cloud_url"
* 以前外观完全一样,现场根本分不出是"曲库坏了"还是"这首歌没有云地址"。
* 现在前者各记一条 `MiliLog.localWarn`(含编号与异常信息)。
*/
private fun fetchFromDb(musicno: String): SourcedSongUrl? {
return try {
val db = MuseDatabase()
if (!db.open()) {
MiliLog.localWarn("SONG-DB 曲库未就绪,跳过 cloud_url 回落 编号=$musicno")
return null
}
val url = db.getCloudUrl(musicno)
db.close()
// DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验
// (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。
if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) {
SourcedSongUrl(url, "")
} else {
null
}
} catch (e: Exception) {
MiliLog.localWarn(
"SONG-DB 查询 cloud_url 异常 编号=$musicno ${e.javaClass.simpleName}: ${e.message}",
)
null
}
}
/**
* 快速判断是否为 demo URL (不含 JS 的完整逻辑, 仅做硬特征兜底)。
*
* ⚠️ host 一律用 [AdVideoFilter.hostOf](**已剥离 query/fragment,且 URI 解析失败会退化**):
* 旧实现直接 `java.net.URI(url).host`,query 里出现 `|`/空格这类字符时 `URI()` 抛异常,
* 被 `?: return true` 当成 demo —— 合法真源地址会因此被拒("取址失败"的隐蔽来源)。
*
* ⚠️ 裸 IP 不再一律判 demo:命中媒体地址允许清单([AdVideoFilter.isAllowedMediaHost])时
* 放行 —— 直连内网自建源时 DB 里的 `cloud_url` 就是裸 IP。
*/
private fun isLikelyDemoUrl(url: String): Boolean {
val host = AdVideoFilter.hostOf(url) ?: return true
// 纯 IP 地址 → 高概率 demo(**允许清单里的主机除外**)
if (host.matches(Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")) &&
!AdVideoFilter.isAllowedMediaHost(host)) return true
// Cloudflare R2 demo bucket (pub-*.r2.dev)
if (host.matches(Regex("^pub-[a-z0-9]+\\.r2\\.dev$"))) return true
return false
}
/**
* 重置歌曲源会话(清 JS 侧 token / API 实例)。
*
* 注意:这里原来是**空实现**,导致断网重连后仍复用旧 token —— 现象是
* 「日志提示歌曲源获取成功(地址拿到了),但实际下载失败/403」。
*/
@JvmStatic
fun clearTokenCache() {
bridge?.resetApiSession()
}
/** 语义更明确的别名:断网恢复后重建歌曲源会话。 */
@JvmStatic
fun resetSourceSession() {
bridge?.resetApiSession()
}
/**
* 设置页改了「歌曲源 / 媒体地址允许清单」后**立即**把新配置注入 JS 会话(隐患 1)。
*
* 原生侧由 [SongUrlResolver.applySourceHosts] 立刻生效;JS 侧过去只在下一次"重建会话"
* (`window.api == null`)时才拿到新列表,已构造的 `window.api.sourceOrder` 仍是旧顺序 ——
* 两条取址路径会在同一时刻用**不同的源顺序**。现在保存设置后直接重新注入
* (`KtvApiConfig.*` + `window.api.sourceOrder`),两端立刻一致。
*/
@JvmStatic
fun applySourceSettings() {
bridge?.applySourceSettingsNow()
}
/** 最近一次取址命中的源:优先 JS 上报,其次原生兜底记录(供远程日志的「请求源=」)。 */
@JvmStatic
fun lastSongSourceHost(): String =
bridge?.lastSongSourceHost?.takeIf { it.isNotEmpty() } ?: SongUrlResolver.lastHost
}