点歌延迟优化:终态失败不退避 + 上游 i.php token 缓存 + 后台预热 + 分阶段耗时日志(V1.6.3/46,未发布)

This commit is contained in:
zijunchiang
2026-10-14 19:25:43 +08:00
parent d01269e917
commit c7f8224a73
21 changed files with 2779 additions and 132 deletions
+2 -2
View File
@@ -20,8 +20,8 @@ android {
applicationId "com.local.ktv"
minSdk 21
targetSdk 25
versionCode 45
versionName "1.6.2"
versionCode 46
versionName "1.6.3"
ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
+66 -8
View File
@@ -12,7 +12,7 @@
(function(global) {
'use strict';
// KTV_BRIDGE_API: 15 (Android 缓存校验标记,请勿删除)
// KTV_BRIDGE_API: 16 (Android 缓存校验标记,请勿删除)
// ─── 配置 ──────────────────────────────────────────
const CONFIG = {
@@ -125,6 +125,33 @@
return new Promise(function(resolve) { setTimeout(resolve, ms); });
}
/**
* 重试退避(毫秒)。**只用在"还值得再试一次"的失败上**,首次请求一律立即发出。
*
* ⚠️ 2026-10-15 点歌延迟优化:这里原来是固定 3000ms,而且**风控 403 / 业务码 404 / HTTP 4xx
* 也照退避重试**。那两种失败是"终态"的:风控窗口按**出口 IP**记(几秒内不可能放行,
* 密集重试只会加重限流),"该源没有这首"更是重试多少次都一样。
* 表现就是用户实测的**点歌后 3~5 秒**:首个 `do.php` 在 0.3s 发出、被拒后干等 3 秒,
* 3.3s 才发第二次,再被拒才换源。现在终态失败**不退避、不重试**,直接换下一个源;
* 只有"广告/错号资源"这类可能瞬时抖动的失败才退避重试一次(800ms)。
*/
const RETRY_BACKOFF_MS = 800;
/**
* 该失败是不是"终态"(重试没有意义,应直接换下一个源)。
*
* * HTTP 4xx:请求本身被源站拒绝(403 风控 / 404 没有)、同 token 重试必然一样;
* * 业务码 403:源站风控的真实形态(HTTP 200 + `{"code":403,...}`,2026-10-08 实测);
* * 业务码 404:源站约定用它表达"这首还没下载完成/不在库"(service.py)。
*
* ⚠️ 与 Kotlin 侧 `SongUrlResolver.sawForbidden` / `KtvJsBridge.riskForbidden` 同口径。
*/
function isTerminalFailure(httpStatus, code) {
var status = Number(httpStatus || 0);
if (status >= 400 && status < 500) return true;
return code === 403 || code === 404;
}
function nativeResponse(raw) {
var value = JSON.parse(raw || '{}');
if (value.error) throw new Error(value.error);
@@ -301,6 +328,23 @@
KtvApi.prototype = {
_log: function(msg) { if (this.debug) console.log('[KtvApi]', msg); },
/**
* 分阶段耗时日志(**只走 `android.log` → 本地 ktv.log**,不上报远程)。
*
* `__orderAt` 由桥在发起本次取址前注入(= 用户点歌那一刻的 `Date.now()` 基准),
* 因此这里量出来的是**用户感知的"点歌 → 该阶段"时长**,而不是"JS 开始跑之后多久"。
* 没有注入时(例如远程脚本单独调试)什么都不记,不会污染日志。
*/
_timing: function(stage, host) {
try {
if (!this.__orderAt) return;
var ms = Date.now() - this.__orderAt;
if (global.android && typeof global.android.log === 'function') {
global.android.log('FETCH-TIME 阶段=' + stage + ' 自点歌=' + ms + 'ms 源=' + (host || ''));
}
} catch(e) { /* 计时失败不影响取址 */ }
},
/** 将失败的源移到优先级队尾(自动排序)。host 不在列表时忽略。 */
demoteSource: function(host) {
var idx = this.sourceOrder.indexOf(host);
@@ -379,8 +423,11 @@
var sign = md5(params + CONFIG.APP_KEY);
var url = host + '/i.php?' + params + '&sign=' + sign;
self._log('Getting token: ' + host);
// 阶段日志:这一步才是"点歌后真正发出的第一个取址请求"。
self._timing('点歌→i.php请求发出', host);
try {
var resp = await httpGet(url);
self._timing('i.php返回', host);
if (resp.status === 200) {
var data = parseJsonBody(resp.body);
if (data.code === 200) {
@@ -420,8 +467,10 @@
var sign = md5(params + CONFIG.SDK_KEY);
// App 令牌:自建源先 GET /token(原生侧缓存),有令牌才多带一个 X-App-Token 头;
// 官方源(404/405)恒为空对象,请求形态与改造前**逐字节一致**。
this._timing('点歌→do.php请求发出', host);
var resp = await httpGet(host + '/music/do.php?' + params + '&sign=' + sign, null,
appHeaders || appTokenHeader(host));
this._timing('do.php返回', host);
if (resp.status !== 200) {
return {code: null, data: '', httpStatus: resp.status, body: resp.body};
}
@@ -474,11 +523,14 @@
var sentAppToken = !!appHeaders['X-App-Token'];
var data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders);
// token 恒定不变; do.php 403"检测到异常行为"是风控限流, 与 token 过期无关。
// 403/失败/广告命中时同 token 短退避重试一次, 不再清 token 重取 i.php。
// 广告/错号资源(如 wb66 719.ts)也算失败, 退避重试一次等真源。
// ⚠️ 2026-10-15 点歌延迟优化:**首次请求永远立即发出**(上面那一行就是首次请求),
// 这里只决定"要不要再试一次":
// * 终态失败(风控 403 / 业务码 404 / HTTP 4xx)→ **不退避、不重试**,直接换下一个源
// (旧实现固定 `sleep(3000)` 再重试一次 —— 那就是点歌后干等 3 秒的直接原因);
// * 可重试失败(广告/错号资源、HTTP 5xx、异常)→ 退避 RETRY_BACKOFF_MS 后重试一次。
var needRetry = data.code !== 200;
// App 令牌被拒(401 + 令牌无效或已过期):清原生缓存 → 重取 → **立即**重试一次,
// 不走 3 秒退避(那是留给风控/广告资源等真源的)。与 Kotlin 侧 withToken 同一口径。
// 不走退避(那是留给风控/广告资源等真源的)。与 Kotlin 侧 withToken 同一口径。
var tokenRejected = sentAppToken && (
data.httpStatus === 401 ||
(data.body && String(data.body).indexOf('令牌无效或已过期') >= 0));
@@ -492,11 +544,16 @@
invalidateAppToken(host);
appHeaders = appTokenHeader(host);
self._log(hostDetail + ' for ' + musicno + ' ls=' + ls + '; App 令牌被拒 → 重取后只重试一次');
data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders);
} else if (isTerminalFailure(data.httpStatus, data.code)) {
self._log(hostDetail + ' for ' + musicno + ' ls=' + ls +
'; 终态失败(不退避、不重试)→ 换下一个源');
} else {
self._log(hostDetail + ' for ' + musicno + ' ls=' + ls + '; retry once with same token');
await sleep(3000);
self._log(hostDetail + ' for ' + musicno + ' ls=' + ls +
'; retry once with same token after ' + RETRY_BACKOFF_MS + 'ms');
await sleep(RETRY_BACKOFF_MS);
data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders);
}
data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders);
}
if (data.code === 200) {
@@ -510,6 +567,7 @@
hostOk = true;
// 任务3: 点歌成功上报命中的源(Android 侧写入远程日志)。
reportSongResult(true, host, musicno, 'ok');
self._timing('点歌→取到地址', host);
return songUrl;
}
if (isDemoUrl(songUrl, musicno)) {
@@ -547,7 +605,7 @@
// ─── 导出 ──────────────────────────────────────────
global.KtvApi = KtvApi;
global.KtvApiConfig = CONFIG;
global.KtvBridgeApiVersion = 15;
global.KtvBridgeApiVersion = 16;
if (typeof module !== 'undefined' && module.exports) {
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
+59 -32
View File
@@ -112,6 +112,12 @@ class AppTokenSession(
private val retryAt = HashMap<String, Long>()
/** 单测用:每个 host 实际请求过几次 `/token`。 */
private val fetches = HashMap<String, Int>()
/**
* 每个 host 一把门闩:**同一 host 同时只发一次 `/token`**(其余调用等它的结果),
* 但**不同 host 之间互不阻塞** —— 一把全局锁会把"一个慢源"变成"所有源的取令牌都排队"
* (SSO 场景下就是点歌整体变慢)。
*/
private val gates = HashMap<String, Any>()
/** 单参构造:用系统时钟(生产路径)。 */
constructor(get: (String) -> TokenHttp) : this(get, { System.currentTimeMillis() })
@@ -121,43 +127,64 @@ class AppTokenSession(
*
* 返回 **null = 这次不要带 `X-App-Token`**(源不支持 / 暂时取不到 / 已有缓存但过期且取新失败);
* 请求照常发出(服务端 `app_token_enforce=false` 时老请求形态依旧可用)。
*
* ⚠️ 网络 I/O 只在**本 host 的门闩**里做(不在全局锁里):同一 host 不会重复请求,
* 不同 host 不会互相排队。
*/
fun tokenFor(host: String): String? = synchronized(lock) {
fun tokenFor(host: String): String? {
val key = key(host) ?: return null
if (unsupported.contains(key)) return null // 官方源:试过一次就够了
val now = clock()
tokens[key]?.let { entry ->
if (now < entry.expiresAtMs) return entry.token // 未过期 → 直接复用
freshToken(key)?.let { return it }
val gate = synchronized(lock) { gates.getOrPut(key) { Any() } }
synchronized(gate) {
// 双检:等门闩期间别人可能已经取回来了。
freshToken(key)?.let { return it }
synchronized(lock) {
if (unsupported.contains(key)) return null // 官方源:试过一次就够了
if (clock() < (retryAt[key] ?: 0L)) return null // 冷却中:不带令牌,也不发请求
fetches[key] = (fetches[key] ?: 0) + 1
}
val now = clock()
val resp = runCatching { get(AppToken.endpoint(key)) }.getOrNull()
// 网络异常/超时:只写日志(调用方负责),这里静默降级成"不带令牌",绝不影响取址与播放。
if (resp == null) {
synchronized(lock) { retryAt[key] = clock() + AppToken.FAILURE_BACKOFF_MS }
return null
}
if (AppToken.isUnsupportedStatus(resp.status)) {
synchronized(lock) {
unsupported.add(key)
tokens.remove(key)
retryAt.remove(key)
}
return null
}
if (resp.status !in 200..299) {
synchronized(lock) { retryAt[key] = clock() + AppToken.FAILURE_BACKOFF_MS }
return null
}
val token = AppToken.parseToken(resp.body)
if (token == null) {
synchronized(lock) { retryAt[key] = clock() + AppToken.FAILURE_BACKOFF_MS }
return null
}
val ttlMs = ((AppToken.parseExpiresIn(resp.body) ?: AppToken.DEFAULT_EXPIRES_IN_SEC) * 1000L -
AppToken.EARLY_EXPIRE_MS).coerceAtLeast(1_000L)
synchronized(lock) {
tokens[key] = Entry(token, now + ttlMs)
retryAt.remove(key)
}
return token
}
}
/** 未过期就返回缓存令牌(过期则顺手清掉)。 */
private fun freshToken(key: String): String? {
synchronized(lock) {
val entry = tokens[key] ?: return null
if (clock() < entry.expiresAtMs) return entry.token
tokens.remove(key)
}
if (now < (retryAt[key] ?: 0L)) return null // 冷却中:不带令牌,也不发请求
fetches[key] = (fetches[key] ?: 0) + 1
val resp = runCatching { get(AppToken.endpoint(key)) }.getOrNull()
// 网络异常/超时:只写日志(调用方负责),这里静默降级成"不带令牌",绝不影响取址与播放。
if (resp == null) {
retryAt[key] = now + AppToken.FAILURE_BACKOFF_MS
return null
}
if (AppToken.isUnsupportedStatus(resp.status)) {
unsupported.add(key)
tokens.remove(key)
retryAt.remove(key)
return null
}
if (resp.status !in 200..299) {
retryAt[key] = now + AppToken.FAILURE_BACKOFF_MS
return null
}
val token = AppToken.parseToken(resp.body)
if (token == null) {
retryAt[key] = now + AppToken.FAILURE_BACKOFF_MS
return null
}
val ttlMs = ((AppToken.parseExpiresIn(resp.body) ?: AppToken.DEFAULT_EXPIRES_IN_SEC) * 1000L -
AppToken.EARLY_EXPIRE_MS).coerceAtLeast(1_000L)
tokens[key] = Entry(token, now + ttlMs)
retryAt.remove(key)
return token
}
/** 清掉某源的令牌缓存(**401** 时调用)。 */
+107 -39
View File
@@ -72,7 +72,7 @@ class KtvJsBridge(context: Context) {
})()"""
/** 热更新脚本的兼容标记(`ktv_api.js` 两处 + 本类两处校验,改协议必须一起改)。 */
private const val BRIDGE_API_MARKER = "KTV_BRIDGE_API: 15"
private const val BRIDGE_API_MARKER = "KTV_BRIDGE_API: 16"
/**
* 已知 CDN 域名后缀(**白名单**,仅用于 [isTrustedDownloadUrl] 的最后一步)。
@@ -372,18 +372,25 @@ class KtvJsBridge(context: Context) {
*
* 与原生兜底 [SongUrlResolver.applySourceHosts] 读的是**同一份设置**,两条取址路径的源一致。
*/
private fun sourceHostsForJs(): List<String> = runCatching {
val store = KtvStore()
store.load()
store.sourceHosts.takeIf { it.isNotEmpty() } ?: SourceHosts.DEFAULT
}.getOrDefault(SourceHosts.DEFAULT)
private fun sourceHostsForJs(): List<String> = sourceConfigForJs().first
/** 设置项里的媒体地址允许清单(注入 JS 的 `CONFIG.ALLOWED_MEDIA_HOSTS`)。 */
private fun allowedMediaHostsForJs(): List<String> = runCatching {
private fun allowedMediaHostsForJs(): List<String> = sourceConfigForJs().second
/**
* **一次**读盘取出「源列表 + 媒体地址允许清单」。
*
* 取址关键路径上([getSongUrl])必须用这个版本:旧写法两个 getter 各调一次
* `KtvStore().load()`,等于在同一首歌的取址前面串两次 `state.json` 读取 + JSON 解析。
*/
private fun sourceConfigForJs(): Pair<List<String>, List<String>> = runCatching {
val store = KtvStore()
store.load()
store.allowedMediaHosts.takeIf { it.isNotEmpty() } ?: SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS
}.getOrDefault(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS)
val hosts = store.sourceHosts.takeIf { it.isNotEmpty() } ?: SourceHosts.DEFAULT
val allowed = store.allowedMediaHosts.takeIf { it.isNotEmpty() }
?: SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS
hosts to allowed
}.getOrDefault(SourceHosts.DEFAULT to SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS)
/**
* 重置 JS 侧歌曲源会话(断网恢复 / 下载重试时调用)。
@@ -402,36 +409,14 @@ class KtvJsBridge(context: Context) {
}
}
fun getSongUrl(
musicNo: String,
resolution: String,
h265: Boolean,
callback: (String?, String?) -> Unit,
) {
if (!ready || destroyed) {
callback(null, null)
return
}
val requestId = UUID.randomUUID().toString()
callbacks[requestId] = callback
// 源列表 / 媒体地址允许清单**以 App 设置项为准**(官方源优先、自建源兜底):
// 沿用既有"配置注入"机制(覆写 window.KtvApiConfig),JS 无需重新发版即可换源。
val hosts = sourceHostsForJs()
val allowedHosts = allowedMediaHostsForJs()
val script = """
(function() {
var requestId = ${JSONObject.quote(requestId)};
var completed = false;
function finish(url, host) {
if (completed) return;
completed = true;
clearTimeout(timer);
// 来源标志:显式回传本次命中的源(老 APK 上没有这个方法 → 忽略,退回 onUrl 的旧形态)。
try { android.onSongSource(requestId, host || ''); } catch (e) {}
android.onUrl(requestId, url || '');
}
var timer = setTimeout(function() { finish(''); }, $JS_REQUEST_TIMEOUT_MS);
Promise.resolve().then(async function() {
/**
* `window.KtvApiConfig` 注入 + `window.api` / `window._apiInitPromise` 构造。
*
* ⚠️ **取址与预热共用同一段 JS**([getSongUrl] 与 [warmUpSession]):
* 以前这段只写在取址脚本里,预热再抄一份就必然漂移(协议/配置注入是四两拨千斤的地方)。
* 调用方必须把它放在 `async function` 里(内部 `await`)。
*/
private fun apiBootstrapJs(hosts: List<String>, allowedHosts: List<String>): String = """
if (!window.api) {
// 歌曲源按 device 识别:随机/每次更换的 device 会被判"异常行为"。
// 使用本机当前网卡 mac + 抓包注册的 sn(会话内保持稳定),并保持版本号一致。
@@ -461,6 +446,89 @@ class KtvJsBridge(context: Context) {
});
}
await window._apiInitPromise;
""".trimIndent()
/**
* **取址预热**:把 JS 会话与各源的 `/i.php` token 提前在后台热好(App 启动 / 进入歌曲列表 /
* 回到前台时调用,见 [SourcePreheat])。
*
* 为什么有效:JS 路径的第一次取址原本要现做「构造 `window.api` → `GET /i.php`(DNS + TCP +
* TLS + 往返)」;这一步与"点的是哪首歌"无关,完全可以提前做掉。
* 预热脚本跑完会写一条 `PREHEAT-JS ...` 本地日志,方便现场核对到底热没热。
*
* @return true = 脚本已交给 WebView(**会真的发请求**);false = 桥不可用,调用方应改走原生预热
*/
fun warmUpSession(hosts: List<String>): Boolean {
val view = webView
if (!ready || destroyed || view == null || hosts.isEmpty()) return false
val allowedHosts = sourceConfigForJs().second
val script = """
(function() {
Promise.resolve().then(async function() {
var __preheatAt = Date.now();
${apiBootstrapJs(hosts, allowedHosts)}
var hosts = ${JSONArray(hosts).toString()};
for (var i = 0; i < hosts.length; i++) {
try {
var token = await window.api.getToken(hosts[i], false);
android.log('PREHEAT-JS i.php token ' + (token ? 'ok' : 'fail') +
' 源=' + hosts[i] + ' 用时=' + (Date.now() - __preheatAt) + 'ms');
} catch (e) {
android.log('PREHEAT-JS 源=' + hosts[i] + ' 失败 ' + (e && e.message ? e.message : e));
}
}
android.log('PREHEAT-JS 完成 用时=' + (Date.now() - __preheatAt) + 'ms');
}).catch(function(error) {
android.log('PREHEAT-JS 失败 ' + (error && error.message ? error.message : error));
});
})();
""".trimIndent()
return mainHandler.post {
runCatching { webView?.evaluateJavascript(script, null) }
.onFailure { MiliLog.localWarn("PREHEAT JS 会话预热失败 ${it.javaClass.simpleName}: ${it.message}") }
}
}
fun getSongUrl(
musicNo: String,
resolution: String,
h265: Boolean,
callback: (String?, String?) -> Unit,
) {
if (!ready || destroyed) {
callback(null, null)
return
}
val requestId = UUID.randomUUID().toString()
callbacks[requestId] = callback
// 源列表 / 媒体地址允许清单**以 App 设置项为准**(官方源优先、自建源兜底):
// 沿用既有"配置注入"机制(覆写 window.KtvApiConfig),JS 无需重新发版即可换源。
// ⚠️ 设置只读**一次**盘(以前 sourceHostsForJs + allowedMediaHostsForJs 各读一遍
// state.json,两次解析都在取址关键路径上)。
val config = sourceConfigForJs()
val hosts = config.first
val allowedHosts = config.second
// 阶段计时基准:把"用户点歌那一刻"的 JS 时间戳注入脚本,JS 侧的 _timing() 才能量出
// **点歌 → i.php/do.php 请求发出**的真实耗时(否则只能量到"JS 开始跑之后多久")。
val orderAt = SongFetchTrace.elapsedMs(musicNo)
?.let { System.currentTimeMillis() - it } ?: 0L
val script = """
(function() {
var requestId = ${JSONObject.quote(requestId)};
var completed = false;
function finish(url, host) {
if (completed) return;
completed = true;
clearTimeout(timer);
// 来源标志:显式回传本次命中的源(老 APK 上没有这个方法 → 忽略,退回 onUrl 的旧形态)。
try { android.onSongSource(requestId, host || ''); } catch (e) {}
android.onUrl(requestId, url || '');
}
var timer = setTimeout(function() { finish(''); }, $JS_REQUEST_TIMEOUT_MS);
Promise.resolve().then(async function() {
${apiBootstrapJs(hosts, allowedHosts)}
// 分阶段耗时日志的基准(0 = 没有点歌记录,JS 侧不记任何计时行)。
window.api.__orderAt = $orderAt;
var url = await window.api.getSongUrl(
${JSONObject.quote(musicNo)}, ${JSONObject.quote(resolution)}, ${if (h265) "true" else "false"}
);
@@ -576,6 +576,9 @@ class MainActivity : AppCompatActivity() {
}
// Keep initialization on the loading state until the external catalog is ready.
SongApiClient.init(this) // 主线程初始化 JS Bridge
// 取址预热(后台、不阻塞启动):把第一个源的 App 令牌 / 上游 i.php token / DNS·TCP·TLS
// 提前热好,用户第一次点歌就不必等这些一次性开销(见 SourcePreheat)。
SourcePreheat.kick(Preheat.REASON_STARTUP)
initializeDatabase()
startRemoteServer()
updateMobileQrOverlay()
@@ -802,6 +805,8 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
// 回到前台:长时间挂后台后连接/令牌可能已经凉了,后台补一次取址预热(非阻塞、失败只写本地日志)。
SourcePreheat.kick(Preheat.REASON_RESUME)
}
@Deprecated("Deprecated in Android")
@@ -3771,8 +3776,10 @@ class MainActivity : AppCompatActivity() {
toast("重新生成设备身份失败:${error.message}")
return
}
// 旧 token 绑在旧 device 上:清掉歌曲源会话,下一次取址用新身份重新登录。
// 旧 token 绑在旧 device 上:清掉歌曲源会话 + 原生侧的上游 /i.php 缓存,
// 下一次取址用新身份重新登录。
runCatching { SongApiClient.clearTokenCache() }
runCatching { SongApiClient.clearUpstreamTokens() }
runCatching { sendDeviceHeartbeat(boot = false) } // 后台线程里发,不阻塞界面
MiliLog.local(
"DEVICE-ID 已重新生成 模板=${ident.preset} mac=${ident.mac} sn=${ident.sn} " +
@@ -3829,7 +3836,12 @@ class MainActivity : AppCompatActivity() {
DeviceMac.refresh()
refreshLogDeviceContext()
SongApiClient.resetSourceSession()
// 换了出口 IP:上游 /i.php 的 token 与源站的试用窗口一样,都可能已经失效
// (源站按「出口 IP + device」识别)→ 清掉原生侧的上游 token 缓存,强制重取。
SongApiClient.clearUpstreamTokens()
Eac19WindowGuard.noteUrlOk()
// 网络刚恢复/换网:连接与令牌都是凉的,后台补一次取址预热(非阻塞)。
SourcePreheat.kick(Preheat.REASON_NETWORK)
updateMobileQrOverlay()
updateFullScreenQrOverlay()
val retried = SongOkDownloadManager.retryNetworkFailed()
@@ -7596,6 +7608,9 @@ class MainActivity : AppCompatActivity() {
/** 顶栏搜索按钮 - 跳到搜索页 */
private fun showSearchPage() {
// 进入点歌/歌曲列表界面:用户已经在挑歌,马上就会点 —— 后台把取址要用的东西预热好
// (非阻塞、失败只写本地日志,见 SourcePreheat)。
SourcePreheat.kick(Preheat.REASON_SONG_LIST)
searchScope = "song"
activeSearchQuery = ""
setSearchText("")
@@ -9791,6 +9806,8 @@ class MainActivity : AppCompatActivity() {
* 加载浏览分页数据
*/
private fun loadBrowsePage() {
// 浏览/点歌列表页:进入即后台预热取址链路(节流 + 单飞,重复调用近乎零成本)。
SourcePreheat.kick(Preheat.REASON_SONG_LIST)
if (!library.muse.isAvailable()) {
catalogRefreshPending = true
busy(true, "曲库加载中...")
+158
View File
@@ -0,0 +1,158 @@
package com.local.ktv
/**
* 取址**预热**的纯逻辑:节流 + 单飞 + 步骤编排(**纯 JVM**,不碰 Android API → 可单测)。
*
* ## 为什么要预热
* 用户点歌到"歌曲源请求真的发出"之间,原先串着一堆**一次性**开销:
* 源站 `/i.php` 的往返、自建源 `/token` 的往返(10 分钟有效,却懒到第一次取址才去取)、
* 以及 DNS 解析 + TCP 握手(HTTPS 还要多一次 TLS 握手)。这些都不依赖"点的是哪首歌",
* 因此可以在用户还没点歌时**提前在后台做掉**,把这几百毫秒到几秒从点歌关键路径上摘掉。
*
* ## 硬约束(本类的全部行为)
* 1. **绝不阻塞调用方**:`kick()` 只做"判断 + 交给后台线程",立刻返回(UI 线程可以放心调用);
* 2. **同一时刻只跑一轮**(单飞):预热本身会发请求,堆叠调用会变成对源站的密集探测
* (源站按出口 IP 限流,见 [Eac19WindowGuard]);
* 3. **同一触发原因节流**:同一个 reason 在 [minIntervalMs] 内只跑一轮;
* 4. **失败只写日志**:任何一步抛异常都只记一条本地日志,**绝不外抛**(预热失败绝不影响点歌);
* 5. **步骤有先后**:先做"会真的发出 HTTP 请求"的那一步(它顺带把 DNS/TCP/TLS 都热好),
* 只有当它没发请求时才补一次裸传输预热 —— 避免对源站重复建连。
*
* 生产接线见 [SourcePreheat];本类只负责"什么时候跑、按什么顺序跑、失败怎么办"。
*
* @param hosts 当前源顺序(**按顺序预热前 [MAX_WARM_HOSTS] 个源**:官方源优先,
* 但它一旦被限流,兜底源才是真正被用到的那个,所以不能只热第一个)
* @param runOn 后台执行器(生产 = 专用守护线程池;测试 = 可注入的同步/阻塞执行器)
* @param warmJsSession 预热 JS 会话(构造 `window.api` + 逐个源取一次 `/i.php` token)。
* **返回 true = 这次真的发出了 HTTP 请求**(DNS/TCP/TLS 已随之热好)
* @param warmUpstreamToken 预热原生侧的 `/i.php` token([SourceTokenCache],带 TTL)
* @param warmTransport 裸传输预热(DNS 解析 + TCP 连接,HTTPS 再握一次手)
* @param warmAppToken 预热自建源 App 令牌(`GET /token`,[AppTokenClient] 内部有缓存)
* @param warmSourceConfig 预热源站 `api.php` 配置(每轮只做一次;它以前**同步**卡在取址最前面)
*/
class Preheat(
private val hosts: () -> List<String>,
private val runOn: (Runnable) -> Unit,
private val warmJsSession: (List<String>) -> Boolean,
private val warmUpstreamToken: (String) -> Unit,
private val warmTransport: (String) -> Unit,
private val warmAppToken: (String) -> Unit,
private val warmSourceConfig: () -> Unit = {},
private val clock: () -> Long = { System.currentTimeMillis() },
private val log: (String) -> Unit = { MiliLog.local(it) },
private val minIntervalMs: Long = DEFAULT_MIN_INTERVAL_MS,
) {
private val lock = Any()
private var running = false
/** 每个触发原因各自节流(`startup` / `js-ready` / `song-list` / `resume` …)。 */
private val lastRunAtByReason = HashMap<String, Long>()
/** 单测用:实际跑过的轮数。 */
@Volatile private var rounds = 0
/**
* 触发一轮预热(**非阻塞**)。
*
* @return true = 这一轮已交给后台;false = 正在跑 / 该原因在节流窗口内 / 后台拒绝执行。
*/
fun kick(reason: String): Boolean {
val now = clock()
synchronized(lock) {
if (running) return false
val last = lastRunAtByReason[reason] ?: 0L
if (now - last < minIntervalMs) return false
lastRunAtByReason[reason] = now
running = true
}
val task = Runnable {
try {
runOnce(reason)
} catch (_: Throwable) {
// runOnce 内部已逐步兜底;这里再兜一层,绝不让异常逃到后台线程之外。
} finally {
synchronized(lock) { running = false }
}
}
return try {
runOn(task)
true
} catch (t: Throwable) {
// 执行器拒绝(线程池满/已关闭)也不能影响调用方。
synchronized(lock) { running = false }
log("PREHEAT 调度失败 reason=$reason ${t.javaClass.simpleName}: ${t.message}")
false
}
}
/** 是否有一轮正在后台跑(单测/自检用)。 */
fun isRunning(): Boolean = synchronized(lock) { running }
/** 实际完成/开始的轮数(单测用)。 */
fun roundCount(): Int = rounds
/** 同步跑一轮(**仅供单测**:生产必须走 [kick],否则就阻塞调用方了)。 */
fun runOnceForTest(reason: String) = runOnce(reason)
private fun runOnce(reason: String) {
rounds++
val startedAt = clock()
val warmHosts = runCatching { hosts().take(MAX_WARM_HOSTS) }.getOrDefault(emptyList())
if (warmHosts.isEmpty()) {
log("PREHEAT 跳过 reason=$reason(没有可用源)")
return
}
// ① 会真的发出 HTTP 请求的那一步(JS 就绪时优先):它顺带把 DNS/TCP/TLS 与
// JS 侧的 token 缓存都热好,因此**只有它没发请求时**才补原生侧/裸传输预热。
var httpWarmed = false
step(reason, warmHosts.first(), "js-session") { httpWarmed = warmJsSession(warmHosts) }
// 源站 api.php 配置:旧实现**同步**走在每次取址的最前面(首个请求不是 /i.php 而是它),
// 现在挪到这里(30 分钟 TTL,幂等)。
step(reason, warmHosts.first(), "source-config") { warmSourceConfig() }
for (host in warmHosts) {
if (!httpWarmed) {
// JS 桥不可用/未就绪时,原生兜底路径才是主力:热原生侧的 token 缓存
// (每个源一次,之后由 TTL 缓存 + 单飞兜住,重复预热近乎零成本)。
step(reason, host, "upstream-token") { warmUpstreamToken(host) }
}
// 裸传输预热:DNS 解析 + TCP 连接(HTTPS 再握一次手)。每次取址都是**新连接**
// (干净请求不做 keep-alive),所以这里热的是 DNS 缓存与 TLS 会话票据。
step(reason, host, "transport") { warmTransport(host) }
// 自建源的 App 令牌(10 分钟有效)。官方源第一次会拿到 404/405 并被
// AppTokenSession 永久记住"不支持",之后连请求都不发。
step(reason, host, "app-token") { warmAppToken(host) }
}
log(
"PREHEAT 完成 reason=$reason 源=${warmHosts.joinToString(" > ")} " +
"用时=${clock() - startedAt}ms http预热=$httpWarmed",
)
}
private fun step(reason: String, host: String, name: String, action: () -> Unit) {
val at = clock()
try {
action()
log("PREHEAT 步骤 ok reason=$reason 步骤=$name 源=$host 用时=${clock() - at}ms")
} catch (t: Throwable) {
// 预热失败只写本地日志:绝不影响取址/播放(预热本来就是"尽力而为")。
log(
"PREHEAT 步骤失败 reason=$reason 步骤=$name 源=$host " +
"${t.javaClass.simpleName}: ${t.message}",
)
}
}
companion object {
/** 同一个触发原因的节流窗口。 */
const val DEFAULT_MIN_INTERVAL_MS = 20_000L
/** 一轮预热最多预热几个源(防止源列表很长时一次打出太多请求)。 */
const val MAX_WARM_HOSTS = 3
/** 触发原因(远程日志/本地日志里能看到到底是哪一次触发的)。 */
const val REASON_STARTUP = "startup"
const val REASON_JS_READY = "js-ready"
const val REASON_SONG_LIST = "song-list"
const val REASON_RESUME = "resume"
const val REASON_NETWORK = "network-up"
}
}
@@ -36,6 +36,9 @@ object SongApiClient {
ready = ok
// 失败原因由 KtvJsBridge.markUnavailable() 统一上报(只记一次),此处不再重复记日志。
Log.i(TAG, "JS Bridge init: $ok")
// 状态跃迁:桥刚就绪时补一次预热 —— 启动那次预热通常发生在 WebView 就绪**之前**,
// 走的还是原生兜底路径;这里补上"JS 会话 + 各源 /i.php token"的预热。
if (ok) runCatching { SourcePreheat.kick(Preheat.REASON_JS_READY) }
}
}
@@ -121,6 +124,7 @@ object SongApiClient {
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
// 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。
val gateStartedAt = System.currentTimeMillis()
val deadline = System.currentTimeMillis() + 15_000L
while (!bridge.isReady() && System.currentTimeMillis() < deadline && !bridge.isDestroyed()) {
try { Thread.sleep(100) } catch (_: InterruptedException) {
@@ -128,6 +132,11 @@ object SongApiClient {
break
}
}
// 阶段日志:这一段就是"点歌 → 真正发请求"之间可能白等的地方(桥就绪闸门)。
SongFetchTrace.stage(
musicno, "闸门(等 JS 桥就绪)",
"用时=${System.currentTimeMillis() - gateStartedAt}ms ready=${bridge.isReady()}",
)
if (!bridge.isReady()) {
// ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是
// 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器,
@@ -141,6 +150,7 @@ object SongApiClient {
}
val result = AtomicReference<SourcedSongUrl?>()
val latch = CountDownLatch(1)
val jsStartedAt = System.currentTimeMillis()
bridge.getSongUrl(musicno, resolution, h265) { url, sourceHost ->
// JS 同时回传"本次命中的源"(第二参)→ 构造**显式来源标志**。
result.set(url?.takeIf { it.isNotEmpty() }?.let { SourcedSongUrl(it, sourceHost.orEmpty()) })
@@ -152,6 +162,11 @@ object SongApiClient {
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
// 阶段日志:JS 全程耗时(含它自己的 i.php / do.php / 退避)。
SongFetchTrace.stage(
musicno, "JS 会话取址全程",
"用时=${System.currentTimeMillis() - jsStartedAt}ms 有地址=${!result.get()?.url.isNullOrEmpty()}",
)
// 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会,
// 避免 JS 侧偶发失败直接把第一首歌判死。
val resolved = SongSourceFlow.firstAvailable(
@@ -163,6 +178,7 @@ object SongApiClient {
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
Eac19WindowGuard.noteUrlOk()
}
SongFetchTrace.stage(musicno, "取址完成", "命中=${resolved?.sourceHost.orEmpty()}")
return resolved
}
@@ -264,4 +280,32 @@ object SongApiClient {
@JvmStatic
fun lastSongSourceHost(): String =
bridge?.lastSongSourceHost?.takeIf { it.isNotEmpty() } ?: SongUrlResolver.lastHost
// ─── 取址预热(由 SourcePreheat 在后台调用,见那段注释:绝不阻塞、失败只写本地日志)───
/**
* 预热 JS 会话:构造 `window.api` 并逐个源取一次 `/i.php` token。
*
* @return true = 脚本已交给 WebView(会真的发出 HTTP 请求,DNS/TCP/TLS 随之热好)
*/
@JvmStatic
fun warmUpJsSession(hosts: List<String>): Boolean =
runCatching { bridge?.warmUpSession(hosts) ?: false }
.onFailure { MiliLog.localWarn("PREHEAT JS 会话预热异常 ${it.javaClass.simpleName}: ${it.message}") }
.getOrDefault(false)
/** 预热原生兜底路径的上游 `/i.php` token(带 TTL 缓存 + 单飞)。 */
@JvmStatic
fun warmUpstreamToken(host: String): String? = SongUrlResolver.preheatUpstreamToken(host)
/** 预热源站 `api.php` 配置(把它从取址关键路径上挪走;见 [SongUrlResolver.preheatConfig])。 */
@JvmStatic
fun warmSourceConfig() {
runCatching { SongUrlResolver.preheatConfig() }
.onFailure { MiliLog.localWarn("PREHEAT api.php 配置预热异常 ${it.javaClass.simpleName}: ${it.message}") }
}
/** 清掉原生侧的上游 token 缓存(换设备身份后调用)。 */
@JvmStatic
fun clearUpstreamTokens() = SongUrlResolver.clearUpstreamTokens()
}
@@ -0,0 +1,102 @@
package com.local.ktv
import java.util.LinkedHashMap
/**
* 取址链路的**分阶段耗时**记录(纯 JVM,可单测)。
*
* ## 为什么需要
* "点歌后要等几秒才发出取址请求"这类问题,只看"成功/失败"的日志是定位不了的:
* 必须知道**每一段**各花了多久 —— 主线程处理、io 池排队、桥就绪等待、`/i.php`、
* `/music/do.php`、地址闸门。这个类就是那条时间轴。
*
* ## 约定
* * 全部走 [MiliLog.local](**只写本地** `<存储根>/logs/ktv.log`)——
* 它是**过程**日志,按 AGENTS「远程日志只留结论、过程走本地」的约定不该上远程;
* * 以**点歌编号**为键:点歌在主线程开始,取址在 io 线程、JS 在 WebView 线程,
* 跨线程传递时间戳只能靠一个共享的键值表(不改任何函数签名);
* * 有界([maxEntries]),满了丢最旧的,绝不无限增长。
*/
class FetchTrace(
private val clock: () -> Long = { System.currentTimeMillis() },
private val sink: (String) -> Unit = { MiliLog.local(it) },
private val maxEntries: Int = 32,
) {
private val lock = Any()
/** 点歌编号 → 本轮"点歌时刻"(毫秒)。LinkedHashMap 保证能按插入顺序淘汰最旧的。 */
private val orderAtById = LinkedHashMap<String, Long>()
/** 记录一次点歌(同一编号重复点歌时以**最后一次**为准:那就是本轮取址的起点)。 */
fun beginOrder(id: String?) {
val key = normalize(id) ?: return
synchronized(lock) {
orderAtById.remove(key)
orderAtById[key] = clock()
while (orderAtById.size > maxEntries) {
val oldest = orderAtById.keys.firstOrNull() ?: break
orderAtById.remove(oldest)
}
}
}
/** 距离本轮点歌已经过去多久(没有记录时返回 null)。 */
fun elapsedMs(id: String?): Long? {
val key = normalize(id) ?: return null
val at = synchronized(lock) { orderAtById[key] } ?: return null
return (clock() - at).coerceAtLeast(0L)
}
/**
* 写一条阶段耗时:`FETCH-TIME 编号=… 阶段=… 自点歌=xxms 详情=…`。
*
* 没有点歌记录时(例如后台自动重试)只记阶段名,不编造耗时。
*/
fun stage(id: String?, stage: String, detail: String = "") {
val elapsed = elapsedMs(id)
val cost = if (elapsed == null) "自点歌=?" else "自点歌=${elapsed}ms"
val tail = if (detail.isEmpty()) "" else " 详情=$detail"
sink("FETCH-TIME 编号=${normalize(id).orEmpty()} 阶段=$stage $cost$tail")
}
/** 该编号是否有进行中的记录(单测用)。 */
fun hasOrder(id: String?): Boolean = synchronized(lock) { orderAtById.containsKey(normalize(id).orEmpty()) }
/** 当前记录条数(单测用:核对有界)。 */
fun size(): Int = synchronized(lock) { orderAtById.size }
fun clear() {
synchronized(lock) { orderAtById.clear() }
}
private fun normalize(id: String?): String? = id?.trim()?.takeIf { it.isNotEmpty() }
}
/**
* 全局实例(生产用)。
*
* `sink` 可被替换(单测里换成"收集到 list"),默认就是 [MiliLog.local]。
*/
object SongFetchTrace {
@Volatile
private var trace = FetchTrace()
/** 记录点歌起点(在点歌/下载入口调用,UI 线程安全:只写一个 map)。 */
@JvmStatic
fun beginOrder(id: String?) = trace.beginOrder(id)
/** 记一条阶段耗时(只写本地日志)。 */
@JvmStatic
fun stage(id: String?, stage: String, detail: String = "") = trace.stage(id, stage, detail)
@JvmStatic
fun elapsedMs(id: String?): Long? = id?.let { trace.elapsedMs(it) }
/** 单测用:换掉实现(返回旧的,便于 after 恢复)。 */
@JvmStatic
fun swapForTest(replacement: FetchTrace): FetchTrace {
val old = trace
trace = replacement
return old
}
}
@@ -154,6 +154,9 @@ object SongOkDownloadManager {
@JvmStatic
fun download(song: Song, callback: DownloadCallback?) {
// 阶段计时的起点 = 用户点歌这一刻(本方法由 MainActivity 在**主线程**同步调用)。
val traceId = musicNoOf(song)
SongFetchTrace.beginOrder(traceId)
if (isDownloaded(song)) {
val localFile = getLocalFile(song)
Log.i(TAG, "Using verified local file: ${localFile.absolutePath}, ${localFile.length()} bytes")
@@ -167,11 +170,15 @@ object SongOkDownloadManager {
}
io.execute {
try {
// 阶段日志:这一段量的是"点歌 → 取址任务真的开始跑",
// 包含主线程剩余处理 + **io 池排队**(4 个线程会被下载/取址任务占住)。
SongFetchTrace.stage(traceId, "点歌→取址任务开始(含 io 池排队)")
var url = buildDownloadUrl(song)
if (url.isEmpty()) {
// 首次下载偶发解析不到地址(JS 桥冷启动/节点瞬时抖动)。短暂退避后重试一次,
// 复现“手动重试即恢复”的体验,避免第一首歌一次性报失败。
Log.w(TAG, "First URL resolve failed for ${song.title}, retrying once")
SongFetchTrace.stage(traceId, "首次取址失败→1200ms 后重试")
Thread.sleep(1_200)
url = buildDownloadUrl(song, forceRefresh = true)
}
@@ -250,7 +257,7 @@ object SongOkDownloadManager {
// `song.id` 在曲库条目里可能是数据库主键(非点歌编号),拿它去比对会把**正常地址误杀**
// (表现:真源地址被判"文件名不符"→ 永远下不到歌)。此时传 null = 跳过文件名规则,
// 仅保留广告域名/路径特征那两条判定。
val musicNoFromFile = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls")?.takeIf { it.isNotBlank() }
val musicNoFromFile = musicNoOf(song)
val musicNo = musicNoFromFile ?: song.id
if (!forceRefresh) {
song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached ->
@@ -545,4 +552,11 @@ object SongOkDownloadManager {
?: song.filename?.takeIf(String::isNotEmpty)
?: song.title.orEmpty()
/**
* 「从文件名推出的点歌编号」(`7678785.ts` / `7586669.ls` → `7678785`)。
* 与 [buildDownloadUrl] 用的是**同一个**表达式(阶段计时与闸门必须同口径)。
*/
private fun musicNoOf(song: Song): String? =
song.filename?.removeSuffix(".ts")?.removeSuffix(".ls")?.takeIf { it.isNotBlank() }
}
@@ -49,6 +49,26 @@ object SongUrlResolver {
/** `api.php` 配置缓存时长(原版每次启动/长时间后取一次即可)。 */
private const val CONFIG_TTL_MS = 30 * 60_000L
/**
* 上游 `/i.php` token 的**按 host 缓存**(TTL 按源站返回的 `heart_time`,见 [SourceTokenCache])。
*
* ⚠️ 这是"点歌延迟优化"的核心之一:旧实现每首歌、每个源都无条件重打一次 `/i.php`,
* 那一次往返(弱网下还要算 DNS + TCP + TLS)全部排在 `/music/do.php` 之前。
* 协议本身**一个字都没改**:还是同一个带签名的 GET,只是把响应按源站自报的有效期复用。
*/
private val tokenCache = SourceTokenCache(load = { host -> loadUpstreamToken(host) })
/** 预热入口:把第一个源的 `/i.php` token 提前取好([SourcePreheat] 在后台调用)。 */
fun preheatUpstreamToken(host: String): String? =
runCatching { tokenCache.tokenFor(host) }
.onFailure {
MiliLog.localWarn("PREHEAT i.php 预热异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
}
.getOrNull()
/** 清空上游 token 缓存(换设备身份 / 断网恢复后调用,强制重新取令牌)。 */
fun clearUpstreamTokens() = tokenCache.clear()
/**
* 歌曲源入口默认值:**官方源优先、自建源兜底**(2026-10-14 反转)。
*
@@ -91,6 +111,9 @@ object SongUrlResolver {
@Volatile private var config: SourceConfig? = null
@Volatile private var configAt: Long = 0L
/** `api.php` 配置预热的单飞锁(预热与取址可能并发)。 */
private val configLock = Any()
/**
* 上报给源站的设备身份 `sn`/`mac`:**与 whoami 心跳、`i.php` 同一份**
* (`DeviceIdentity` 生成一次、落盘后固定不变)。
@@ -181,41 +204,48 @@ object SongUrlResolver {
}
/**
* 拉取源站配置(原版客户端在取址前会调一次 `api.php`),缓存 [CONFIG_TTL_MS]。
* **后台**拉取源站配置(原版客户端在取址前会调一次 `api.php`),缓存 [CONFIG_TTL_MS]。
*
* 用途:① 日志里给出"是否启用试用/试用次数/时长/是否需要卡密"这些**结论**;
* ② 按 `trial_times` 限制一次取址尝试的源数量,避免把试用额度瞬间耗尽。
*
* ⚠️ **点歌延迟优化**:旧实现是**同步**走在取址关键路径最前面的 —— 一首歌发出的第一个请求
* 不是 `/i.php` 而是这个 `POST api.php`(当前官方节点根本没有这个接口,白等一次完整往返)。
* 现在它只由 [SourcePreheat] 在后台预热调用;点歌路径只用**已经拿到的**配置,
* 拿不到就按全部源尝试(与旧的负缓存兜底语义一致)。
*/
private fun ensureConfig(host: String, mac: String) {
fun preheatConfig(host: String = currentHosts().firstOrNull().orEmpty(), mac: String = this.mac) {
if (host.isEmpty()) return
if (System.currentTimeMillis() - configAt < CONFIG_TTL_MS) return
val channel = post ?: return
val form = "action=get_config&device_id=$mac"
val raw = runCatching { channel("$host/api.php", form) }.getOrNull() ?: return
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
// ⚠️ 失败也要进负缓存:本节点(zk.ktv1.top:6677)**没有 api.php**(404),
// 不缓存的话每首歌取址前都要白请求一次,既拖慢又白耗额度。
configAt = System.currentTimeMillis()
val status = envelope.optInt("status")
if (status !in 200..299) {
MiliLog.localWarn("SOURCE-CONFIG api.php HTTP $status(本节点无配置接口,按全部源尝试)")
return
}
val body = runCatching { JSONObject(envelope.optString("body")) }.getOrNull() ?: return
val c = body.optJSONObject("config") ?: body
val times = c.optInt("trial_times", 0)
val dur = c.optInt("trial_duration", 0)
val verify = c.optBoolean("network_verification", false)
config = SourceConfig(times, dur, verify)
configAt = System.currentTimeMillis()
// 远程只留一条"结论";完整 JSON 与卡密提示只写本地。
MiliLog.info(
"SOURCE-CONFIG trial_enabled=${c.optBoolean("trial_enabled", false)} " +
"trial_times=$times trial_duration=${dur}min network_verification=$verify " +
"heartbeat=${c.optInt("heartbeat_interval", 0)}s",
)
MiliLog.local("SOURCE-CONFIG raw=${body.toString().take(300)}")
if (verify && c.optString("dialog_title").isNotEmpty()) {
MiliLog.local("SOURCE-CONFIG 源站要求卡密:${c.optString("dialog_title")}")
synchronized(configLock) {
if (System.currentTimeMillis() - configAt < CONFIG_TTL_MS) return
// 先占位(失败也进负缓存),避免预热重复发起 api.php。
configAt = System.currentTimeMillis()
val form = "action=get_config&device_id=$mac"
val raw = runCatching { channel("$host/api.php", form) }.getOrNull() ?: return
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
val status = envelope.optInt("status")
if (status !in 200..299) {
MiliLog.localWarn("SOURCE-CONFIG api.php HTTP $status(本节点无配置接口,按全部源尝试)")
return
}
val body = runCatching { JSONObject(envelope.optString("body")) }.getOrNull() ?: return
val c = body.optJSONObject("config") ?: body
val times = c.optInt("trial_times", 0)
val dur = c.optInt("trial_duration", 0)
val verify = c.optBoolean("network_verification", false)
config = SourceConfig(times, dur, verify)
// 远程只留一条"结论";完整 JSON 与卡密提示只写本地。
MiliLog.info(
"SOURCE-CONFIG trial_enabled=${c.optBoolean("trial_enabled", false)} " +
"trial_times=$times trial_duration=${dur}min network_verification=$verify " +
"heartbeat=${c.optInt("heartbeat_interval", 0)}s",
)
MiliLog.local("SOURCE-CONFIG raw=${body.toString().take(300)}")
if (verify && c.optString("dialog_title").isNotEmpty()) {
MiliLog.local("SOURCE-CONFIG 源站要求卡密:${c.optString("dialog_title")}")
}
}
}
@@ -248,10 +278,11 @@ object SongUrlResolver {
sawForbidden = false
val mac = this.mac // 与 whoami 心跳 / do.php 同一份设备身份
val startedAt = System.currentTimeMillis()
val urlStartedAt = SongFetchTrace.elapsedMs(musicno)
val allHosts = synchronized(hostOrder) { hostOrder.toList() }
// 原版取址前会先 POST api.php 取配置;这里顺带用它限制一次取址的源数量
// (试用额度只有 trial_times 次,扫全部源会把额度瞬间打光)。
ensureConfig(allHosts.firstOrNull().orEmpty(), mac)
// 原版取址前会先 POST api.php 取配置;这里**只用已经预热到的配置**限制一次取址的源数量
// (试用额度只有 trial_times 次,扫全部源会把额度瞬间打光)。⚠️ 取址关键路径上
// **不再发 api.php 请求** —— 那个往返由 SourcePreheat 在后台提前做掉(见 preheatConfig)。
val maxHosts = config?.trialTimes?.takeIf { it > 0 }?.coerceAtMost(allHosts.size) ?: allHosts.size
if (maxHosts < allHosts.size) {
MiliLog.local("NATIVE-SONG 按试用额度限制本次取址源数=$maxHosts(共 ${allHosts.size} 个)")
@@ -263,21 +294,33 @@ object SongUrlResolver {
var adSample = ""
for ((index, host) in hosts.withIndex()) {
val token = runCatching { fetchToken(channel, host, mac) }.getOrElse {
val tokenStartedAt = System.currentTimeMillis()
if (index == 0) {
// 阶段日志:原生路径上"文档里说的取址请求"就是这个 /i.php(命中缓存时为 0ms)。
SongFetchTrace.stage(musicno, "原生取址开始(即将发 i.php)", "源=$host 点歌到此=${urlStartedAt ?: -1}ms")
}
val token = runCatching { fetchToken(host) }.getOrElse {
MiliLog.localWarn("NATIVE-SONG i.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
null
}
SongFetchTrace.stage(
musicno, "i.php", "源=$host 本次=${System.currentTimeMillis() - tokenStartedAt}ms 命中=${token != null}",
)
if (token.isNullOrEmpty()) {
lastDetail = "token-fail"
otherFailCount++
demote(host)
continue
}
val doStartedAt = System.currentTimeMillis()
val url = runCatching { fetchSongUrl(channel, host, mac, musicno, token, resolution, h265) }
.getOrElse {
MiliLog.localWarn("NATIVE-SONG do.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
null
}
SongFetchTrace.stage(
musicno, "do.php", "源=$host 本次=${System.currentTimeMillis() - doStartedAt}ms",
)
if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) {
promote(host)
lastHost = host
@@ -336,12 +379,28 @@ object SongUrlResolver {
// ─── 协议实现 ───────────────────────────────────────
private fun fetchToken(channel: (String, String?) -> String?, host: String, mac: String): String? {
/**
* 取当前源的 `/i.php` token —— **走按 host 的缓存 + 单飞**([SourceTokenCache])。
*
* 缓存 TTL 由源站自己在响应里给的 `heart_time` 决定(实测 5400 秒 = 90 分钟;
* 取不到就用安全默认 90 分钟)。旧实现每首歌、每个源都重打一次 `/i.php`,
* 那一次往返全部排在 `/music/do.php` 之前 —— 正是"点歌后要等几秒才发出取址请求"的
* 组成部分之一。协议本身没有改动(同一个签名 GET,只是复用响应)。
*/
private fun fetchToken(host: String): String? = tokenCache.tokenFor(host)
/**
* 真的去请求 `/i.php`(只在缓存未命中时被 [SourceTokenCache] 调用,且**同一 host 单飞**)。
*
* ⚠️ 参数/签名与官方源**逐字节对齐**:不带 App 令牌(服务端把 `/i.php` 列在
* LEGACY_CHECK_PATHS,"带了就校验" —— 我们没带,老形态完全不变)。
*/
private fun loadUpstreamToken(host: String): UpstreamToken? {
val channel = fetch ?: return null
val mac = this.mac
val ts = System.currentTimeMillis() / 1000
val params = "appid=$APP_ID&mac=${mac}_$sn&sn=$sn&time=$ts&ver=$VER&vn=$VN"
val sign = md5(params + APP_KEY)
// ⚠️ `/i.php` 与官方源**逐字节对齐**:不带 App 令牌(服务端把它列为 LEGACY_CHECK_PATHS,
// "带了就校验" —— 我们没带,老形态完全不变;参数与签名一个字都没改)。
val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null
val tokenCode = json.optInt("code")
if (tokenCode == 403) sawForbidden = true // 业务码 403 = 风控(见 do.php 同处注释)
@@ -349,7 +408,12 @@ object SongUrlResolver {
MiliLog.localWarn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}")
return null
}
return json.optString("token").takeIf { it.isNotEmpty() }
val token = json.optString("token").takeIf { it.isNotEmpty() } ?: return null
// 源站自报的有效时长(秒):heart_time(实测 5400)。App 侧以前完全没读它 ——
// 现在用它当缓存 TTL,语义与源站一致;字段缺失/异常时由 SourceTokenCache 兜底。
val heartTimeSec = json.optLong("heart_time", -1L).takeIf { it > 0L }
MiliLog.local("NATIVE-SONG i.php token 已缓存 源=$host heart_time=${heartTimeSec ?: "?"}s")
return UpstreamToken(token, heartTimeSec)
}
private fun fetchSongUrl(
@@ -371,7 +435,18 @@ object SongUrlResolver {
// 取到就带 `X-App-Token`;官方源返回 404/405 会被记住"不支持令牌",之后不再尝试、也不带该头
// (避免每首歌白多一次请求)。带令牌被拒(401 + 令牌无效或已过期)→ 清缓存重取,
// **只重试一次** —— 全部逻辑见 [AppTokenSession](纯 JVM,有单测)。
val resp = AppTokenClient.withToken(host) { appToken -> callEnvelope(channel, url, appToken) }
//
// ⚠️ 点歌延迟优化:这里**显式先取一次**令牌,只为把"取令牌到底花了多久"量出来
// (阶段日志 app-token)。命中缓存时它是纯内存查表(~0ms);真正发请求只发生在
// 预热没跑到 / 缓存过期时。取到的值与紧接着 [AppTokenClient.withToken] 里的那次
// 完全一样(同一个缓存),因此**请求行为没有任何变化**。
val appTokenAt = System.currentTimeMillis()
val appToken = AppTokenClient.tokenFor(host)
SongFetchTrace.stage(
musicno, "app-token",
"源=$host 本次=${System.currentTimeMillis() - appTokenAt}ms 有令牌=${appToken != null}",
)
val resp = AppTokenClient.withToken(host) { appTokenValue -> callEnvelope(channel, url, appTokenValue) }
if (resp.status !in 200..299) return null // callEnvelope 已记日志(含 401 令牌被拒)
val body = resp.body
if (body.isBlank()) return null
@@ -0,0 +1,99 @@
package com.local.ktv
import java.net.InetSocketAddress
import java.net.Socket
import java.util.concurrent.Executors
import javax.net.ssl.SSLSocket
/**
* 取址预热的 **Android 侧接线**(纯逻辑在 [Preheat])。
*
* ## 触发时机([kick] 的 reason)
* | 时机 | 位置 | 为什么 |
* |---|---|---|
* | App 启动 | `MainActivity.onCreate`([SongApiClient.init] 之后) | 用户第一次点歌前就把令牌/连接热好 |
* | JS 桥就绪 | [SongApiClient.init] 的回调(`ok == true`) | 状态跃迁时补一次 **JS 会话**预热 |
* | 进入点歌/歌曲列表 | `MainActivity` 的浏览页加载入口 | 用户已经在挑歌了,马上就要点 |
* | 回到前台 | `MainActivity.onResume` | 长时间挂后台后连接/令牌可能已经凉了 |
* | 网络恢复 | `MainActivity.refreshNetworkState` | 换了出口 IP,旧令牌/连接全废 |
*
* ## 硬约束
* 1. **不阻塞 UI**:[kick] 只往专用守护线程池丢任务(**不占主线程、也不占下载用的 io 池**——
* io 池的 4 个线程会被几十秒的下载任务占满,预热丢进去等于没预热);
* 2. **失败只写本地日志**:任何一步异常都只记 `MiliLog.localWarn`,绝不影响点歌/播放;
* 3. **节流 + 单飞**:见 [Preheat](同一 reason 20 秒内不重复、同一时刻只跑一轮);
* 裸传输预热再按 host 加一层 60 秒节流(`DNS/TCP/TLS` 握手没必要每轮都做)。
*/
object SourcePreheat {
/** 裸传输预热的每 host 节流窗口(DNS 缓存/TLS 会话票据的有效期远长于此)。 */
private const val TRANSPORT_INTERVAL_MS = 60_000L
/** 裸连接超时(预热是尽力而为,绝不能长时间占着线程)。 */
private const val TRANSPORT_TIMEOUT_MS = 3_000
private val executor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "ktv-preheat").apply { isDaemon = true }
}
private val transportLock = Any()
private val lastTransportAt = HashMap<String, Long>()
private val preheat = Preheat(
hosts = { SongUrlResolver.currentHosts() },
runOn = { task -> runCatching { executor.execute(task) } },
warmJsSession = { hosts -> SongApiClient.warmUpJsSession(hosts) },
warmUpstreamToken = { host -> SongApiClient.warmUpstreamToken(host) },
warmTransport = { host -> warmTransport(host) },
warmAppToken = { host -> AppTokenClient.tokenFor(host) },
warmSourceConfig = { SongApiClient.warmSourceConfig() },
)
/** 触发一轮预热(**非阻塞**,UI 线程可放心调用)。 */
@JvmStatic
fun kick(reason: String) {
runCatching { preheat.kick(reason) }
.onFailure { MiliLog.localWarn("PREHEAT 触发失败 reason=$reason ${it.javaClass.simpleName}: ${it.message}") }
}
/** 是否有预热在跑(自检/日志用)。 */
@JvmStatic
fun isRunning(): Boolean = runCatching { preheat.isRunning() }.getOrDefault(false)
/**
* DNS + TCP(HTTPS 再握手)预热。
*
* 为什么值得做:取址用的是**干净请求**([KtvJsBridge.rawCleanGet] 的裸 Socket),
* 每次请求都新建连接、不做 keep-alive,因此每个源的第一首歌都要付
* 「DNS 解析 + TCP 三次握手(+ TLS 握手)」。这一步与"点的是哪首歌"无关,可以提前做。
*
* ⚠️ HTTPS 必须走 `TlsCompat.sslSocketFactory()` + `createSocket(plain, host, port, true)`
* (先连接再按主机名包装),与取址通道同一口径:老设备(API<24)的系统信任库没有
* ISRG 新根,用默认工厂会握手失败 —— 预热带不上任何好处,还可能误判。
*/
private fun warmTransport(host: String) {
val now = System.currentTimeMillis()
synchronized(transportLock) {
if (now - (lastTransportAt[host] ?: 0L) < TRANSPORT_INTERVAL_MS) return
lastTransportAt[host] = now
}
val uri = runCatching { java.net.URI(host) }.getOrNull() ?: return
val name = uri.host ?: return
val isHttps = uri.scheme.equals("https", ignoreCase = true)
val port = if (uri.port > 0) uri.port else if (isHttps) 443 else 80
// DNS:显式解析一次,把结果放进 JVM/系统解析器缓存(下一次请求省掉一次查询)。
java.net.InetAddress.getByName(name)
val plain = Socket()
try {
plain.connect(InetSocketAddress(name, port), TRANSPORT_TIMEOUT_MS)
if (isHttps) {
(TlsCompat.sslSocketFactory().createSocket(plain, name, port, true) as SSLSocket).apply {
startHandshake()
close()
}
}
} finally {
runCatching { plain.close() }
}
}
}
@@ -0,0 +1,149 @@
package com.local.ktv
/**
* 上游源站 `GET /i.php` 的 token 缓存(**纯 JVM**,不碰 Android API → 可跑纯 JVM 单测)。
*
* ## 为什么需要它
* `SongUrlResolver`(原生兜底取址)原先**每首歌、每个源**都重新打一次 `/i.php`:
* `resolveSourced()` 的循环里无条件调 `fetchToken()`,没有任何缓存。多源 + 弱网时,
* 每首歌都要多付一次「DNS + TCP + 请求 + 响应」的往返,全部排在 `/music/do.php`
* (真正取地址的那个请求)之前 —— 用户感知就是"点了歌要等几秒才发出取址请求"。
*
* ## TTL 按源站语义取
* `/i.php` 的成功响应里带 `heart_time`(实测 `5400` 秒 = 90 分钟,见 CHANGELOG V1.5.5 的
* 逐字节响应记录)。这就是源站自己给出的"心跳/有效期"口径,因此:
* * 拿得到 `heart_time` → 按它缓存(夹在 `[MIN_TTL_SEC, MAX_TTL_SEC]` 之间,防止上游给
* 0 / 异常值把缓存变成"永久"或"立刻过期");
* * 拿不到 → 用安全默认 [DEFAULT_HEART_TIME_SEC](90 分钟)。
*
* ⚠️ **协议一个字都没改**:只是把同一个 `GET <host>/i.php?...&sign=...` 的响应复用起来;
* 缓存过期后照旧重新请求。
*
* ## 并发(同一时刻只发一次)
* 取令牌按 **host 单飞**:同一个 host 上并发的调用只会让**第一个**真正发请求,其余等它的结果
* (不会重复打 `/i.php`)。不同 host 之间**互不阻塞**(各自一把门闩,网络 I/O 不在全局锁里)——
* 这一条很重要:自建源 `/token` 曾经在全局锁里做网络 I/O,一个慢源会把另一个源的取令牌一起卡住。
*
* 失败(网络异常 / 非 2xx / 解析不出 token)会写一个**短冷却**,避免每首歌都白打一次。
*/
class SourceTokenCache(
/** 真正去取 token:返回 null 表示这次失败(网络/非 2xx/没有 token 字段)。 */
private val load: (String) -> UpstreamToken?,
private val clock: () -> Long = { System.currentTimeMillis() },
) {
private class Entry(@JvmField val token: String, @JvmField val expiresAtMs: Long)
private val lock = Any()
private val tokens = HashMap<String, Entry>()
/** 每个 host 一把门闩:只用于"同一 host 同时只发一次 /i.php",不跨 host 阻塞。 */
private val gates = HashMap<String, Any>()
/** 失败后的短冷却(到该时刻为止不再尝试)。 */
private val retryAt = HashMap<String, Long>()
/** 单测用:每个 host 实际发过几次 `/i.php`。 */
private val fetches = HashMap<String, Int>()
/**
* 取当前源的 token(可能直接用缓存)。
*
* @return token;null = 这次用不了(失败 / 冷却中)。
*/
fun tokenFor(host: String): String? {
val key = normalize(host) ?: return null
freshToken(key)?.let { return it }
val gate = synchronized(lock) { gates.getOrPut(key) { Any() } }
synchronized(gate) {
// 双检:等门闩的这段时间里,别人可能已经把 token 取回来了。
freshToken(key)?.let { return it }
val now = clock()
if (now < (retryAt[key] ?: 0L)) return null
synchronized(lock) { fetches[key] = (fetches[key] ?: 0) + 1 }
val loaded = runCatching { load(host) }.getOrNull()
if (loaded == null || loaded.token.isEmpty()) {
synchronized(lock) { retryAt[key] = clock() + FAILURE_BACKOFF_MS }
return null
}
val ttlMs = ttlMs(loaded.heartTimeSec)
synchronized(lock) {
tokens[key] = Entry(loaded.token, clock() + ttlMs)
retryAt.remove(key)
}
return loaded.token
}
}
/** 主动放进缓存(预热用;`ttlMs` 为空时按 [DEFAULT_HEART_TIME_SEC])。 */
fun put(host: String, token: String, heartTimeSec: Long? = null) {
val key = normalize(host) ?: return
if (token.isEmpty()) return
synchronized(lock) {
tokens[key] = Entry(token, clock() + ttlMs(heartTimeSec))
retryAt.remove(key)
}
}
/** 清掉某源的缓存(源站 403/风控后强制重取时用)。 */
fun invalidate(host: String) {
val key = normalize(host) ?: return
synchronized(lock) {
tokens.remove(key)
retryAt.remove(key)
}
}
fun clear() {
synchronized(lock) {
tokens.clear()
retryAt.clear()
}
}
/** 当前缓存的 token 是否还没过期(单测/自检用)。 */
fun hasFreshToken(host: String): Boolean = freshToken(normalize(host).orEmpty()) != null
/** 该 host 实际发过几次 `/i.php`(单测用:核对"命中缓存不重复请求")。 */
fun fetchCount(host: String): Int = synchronized(lock) { fetches[normalize(host).orEmpty()] ?: 0 }
private fun freshToken(key: String): String? {
if (key.isEmpty()) return null
synchronized(lock) {
val entry = tokens[key] ?: return null
if (clock() < entry.expiresAtMs) return entry.token
tokens.remove(key)
return null
}
}
private fun ttlMs(heartTimeSec: Long?): Long {
val seconds = (heartTimeSec ?: DEFAULT_HEART_TIME_SEC).coerceIn(MIN_TTL_SEC, MAX_TTL_SEC)
return seconds * 1000L
}
private fun normalize(host: String?): String? =
host?.trim()?.trimEnd('/')?.takeIf { it.isNotEmpty() }
companion object {
/** 上游没给 `heart_time` 时的安全默认:90 分钟(实测源站给的就是 5400 秒)。 */
const val DEFAULT_HEART_TIME_SEC = 90L * 60L
/** TTL 下界:上游给 0/异常小值时至少缓存 1 分钟,避免退化成"每首歌都打 /i.php"。 */
const val MIN_TTL_SEC = 60L
/** TTL 上界:12 小时(防止上游给出荒谬的大值把缓存变成"永久")。 */
const val MAX_TTL_SEC = 12L * 60L * 60L
/** 取 token 失败后的短冷却。 */
const val FAILURE_BACKOFF_MS = 30_000L
}
}
/**
* 一次成功的 `/i.php` 结果:**token** + 源站自报的有效时长 `heart_time`(秒,可能没有)。
*
* 用**纯数据类**而不是 `org.json.JSONObject`:单测跑在纯 JVM 上,Android 的 `org.json` 是空壳
* (见 [DeviceIdentity] 类头注释),解析必须留在调用方([SongUrlResolver])。
*/
class UpstreamToken @JvmOverloads constructor(
@JvmField val token: String,
@JvmField val heartTimeSec: Long? = null,
)
@@ -213,6 +213,66 @@ public class AppTokenTest {
assertEquals("判定为令牌被拒 → 清缓存并重取一次", 2, server.calls());
}
// ── 并发:同一 host 同时只发一次;不同 host 互不阻塞(2026-10-15 点歌延迟优化)──
@Test
public void concurrentTokenRequestsForSameHostHitServerOnce() throws Exception {
final int threads = 8;
final java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1);
final java.util.concurrent.CountDownLatch done = new java.util.concurrent.CountDownLatch(threads);
final java.util.List<String> results = java.util.Collections.synchronizedList(new java.util.ArrayList<String>());
final java.util.concurrent.atomic.AtomicInteger slowLoads = new java.util.concurrent.atomic.AtomicInteger();
// 让"网络请求"慢一点:并发调用者必须等第一个人的结果,而不是各自再发一次。
AppTokenSession session = new AppTokenSession(url -> {
slowLoads.incrementAndGet();
try { Thread.sleep(120); } catch (InterruptedException ignored) {}
return new TokenHttp(200, "{\"ok\":true,\"token\":\"tk-1\",\"expires_in\":600}");
});
for (int i = 0; i < threads; i++) {
Thread thread = new Thread(() -> {
try { start.await(); } catch (InterruptedException ignored) {}
results.add("token:" + session.tokenFor("https://mdmuse.kasugano.cn"));
done.countDown();
});
thread.start();
}
start.countDown();
assertTrue("并发取令牌应在 5 秒内完成", done.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertEquals("同一 host 并发只能发一次 /token", 1, slowLoads.get());
int tokenCount = 0;
for (String value : results) if (value.equals("token:tk-1")) tokenCount++;
assertEquals("所有并发调用者拿到同一个令牌", threads, tokenCount);
}
@Test
public void slowHostDoesNotBlockAnotherHost() throws Exception {
final java.util.concurrent.CountDownLatch gate = new java.util.concurrent.CountDownLatch(1);
final java.util.concurrent.atomic.AtomicInteger loads = new java.util.concurrent.atomic.AtomicInteger();
AppTokenSession session = new AppTokenSession(url -> {
loads.incrementAndGet();
if (url.startsWith("https://slow.example")) {
try { gate.await(5, java.util.concurrent.TimeUnit.SECONDS); } catch (InterruptedException ignored) {}
return new TokenHttp(200, "{\"ok\":true,\"token\":\"slow\",\"expires_in\":600}");
}
return new TokenHttp(200, "{\"ok\":true,\"token\":\"fast\",\"expires_in\":600}");
});
Thread slow = new Thread(() -> session.tokenFor("https://slow.example"));
slow.start();
Thread.sleep(80); // 让慢源确实进入"网络请求"里
long startedAt = System.nanoTime();
assertEquals("fast", session.tokenFor("https://fast.example"));
long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000L;
assertTrue("不同 host 之间不得互相排队(实测 " + elapsedMs + "ms)", elapsedMs < 500);
gate.countDown();
slow.join(5_000);
assertEquals(2, loads.get());
}
// ── 解析工具 ──
@Test
@@ -0,0 +1,115 @@
package com.local.ktv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.atomic.AtomicLong
/**
* 取址分阶段耗时([FetchTrace])单测。
*
* 关心三件事:① 记的是"点歌 → 该阶段"的真实耗时;② **没有点歌记录时不编造数字**;
* ③ 有界(点歌编号是外部输入,不能无限攒)。
*/
class FetchTraceTest {
private class Recorder {
val now = AtomicLong(1_700_000_000_000L)
val lines = mutableListOf<String>()
fun trace(maxEntries: Int = 32): FetchTrace = FetchTrace(now::get, { lines += it }, maxEntries)
}
@Test
fun stageRecordsElapsedSinceOrder() {
val r = Recorder()
val trace = r.trace()
trace.beginOrder("7303235")
r.now.addAndGet(1_234L)
trace.stage("7303235", "i.php", "源=http://zk.ktv1.top:6677")
assertEquals(1, r.lines.size)
val line = r.lines.first()
assertTrue("阶段行必须是 FETCH-TIME 前缀(便于现场 grep)", line.startsWith("FETCH-TIME "))
assertTrue("必须带编号", line.contains("编号=7303235"))
assertTrue("必须带阶段名", line.contains("阶段=i.php"))
assertTrue("必须带'自点歌'耗时", line.contains("自点歌=1234ms"))
assertTrue("详情要一起带上", line.contains("源=http://zk.ktv1.top:6677"))
}
@Test
fun elapsedIsMeasuredFromTheLatestOrder() {
val r = Recorder()
val trace = r.trace()
trace.beginOrder("7303235")
r.now.addAndGet(5_000L)
assertEquals(5_000L, trace.elapsedMs("7303235"))
// 重新点同一首歌:基准回到这一次(否则重试点歌会报出累计时间)。
trace.beginOrder("7303235")
r.now.addAndGet(100L)
assertEquals(100L, trace.elapsedMs("7303235"))
}
@Test
fun withoutOrderItDoesNotInventNumbers() {
val r = Recorder()
val trace = r.trace()
trace.stage("7303235", "闸门(等 JS 桥就绪)")
assertNull(trace.elapsedMs("7303235"))
assertEquals(1, r.lines.size)
assertTrue("没有点歌记录时不能编造耗时", r.lines.first().contains("自点歌=?"))
assertFalse(r.lines.first().contains("自点歌=0ms"))
}
@Test
fun blankIdsDoNotCreateOrderEntries() {
val r = Recorder()
val trace = r.trace()
trace.beginOrder(" ")
assertFalse(trace.hasOrder(""))
assertNull(trace.elapsedMs(""))
// 空编号的阶段日志仍然要写(现场可能是后台重试),但**不许**编造耗时。
trace.stage(null, "x")
assertEquals(1, r.lines.size)
assertTrue(r.lines.first().contains("自点歌=?"))
}
@Test
fun entriesAreBounded() {
val r = Recorder()
val trace = r.trace(maxEntries = 4)
repeat(10) { trace.beginOrder("song-$it") }
assertEquals("必须丢最旧的,绝不无限增长", 4, trace.size())
assertTrue(trace.hasOrder("song-9"))
assertFalse(trace.hasOrder("song-0"))
}
@Test
fun clearDropsEverything() {
val r = Recorder()
val trace = r.trace()
trace.beginOrder("a")
trace.clear()
assertEquals(0, trace.size())
assertNull(trace.elapsedMs("a"))
}
@Test
fun globalEntryPointDelegatesAndGoesToLocalLog() {
// 生产入口:SongFetchTrace 的默认 sink 是 MiliLog.local(单测里 MiliLog 未 init → 空操作,
// 因此这里只核对"能调用、不抛异常、可替换")。
val r = Recorder()
val old = SongFetchTrace.swapForTest(r.trace())
try {
SongFetchTrace.beginOrder("7678785")
r.now.addAndGet(250L)
SongFetchTrace.stage("7678785", "点歌→取址任务开始(含 io 池排队)")
assertEquals(250L, SongFetchTrace.elapsedMs("7678785"))
assertTrue(r.lines.first().contains("阶段=点歌→取址任务开始"))
} finally {
SongFetchTrace.swapForTest(old)
}
}
}
@@ -0,0 +1,309 @@
package com.local.ktv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
/**
* 取址预热([Preheat] 纯逻辑)单测。
*
* 需求点名的四件事:
* ① **预热不阻塞**调用方(UI 线程);
* ② **同一时刻只跑一轮**(单飞,避免对源站密集探测);
* ③ **失败只写本地日志**(绝不外抛、绝不影响点歌);
* ④ 步骤顺序:会发 HTTP 的那步优先(顺带热好 DNS/TCP/TLS),它没发请求时才补原生侧/裸传输。
*/
class PreheatTest {
private class Fixture {
val logs: MutableList<String> = Collections.synchronizedList(mutableListOf())
val calls: MutableList<String> = Collections.synchronizedList(mutableListOf())
val now = AtomicLong(1_700_000_000_000L)
fun preheat(
hosts: List<String>,
executor: (Runnable) -> Unit,
jsWarms: Boolean,
throwEverything: Boolean,
): Preheat = Preheat(
hosts = {
if (throwEverything) throw IllegalStateException("hosts() 挂了")
hosts
},
runOn = executor,
warmJsSession = { h ->
calls += "js:" + h.joinToString("|")
if (throwEverything) throw IllegalStateException("js 预热挂了")
jsWarms
},
warmUpstreamToken = { h ->
calls += "upstream:$h"
if (throwEverything) throw IllegalStateException("i.php 预热挂了")
},
warmTransport = { h ->
calls += "transport:$h"
if (throwEverything) throw IllegalStateException("传输预热挂了")
},
warmAppToken = { h ->
calls += "token:$h"
if (throwEverything) throw IllegalStateException("/token 预热挂了")
},
warmSourceConfig = {
calls += "config"
if (throwEverything) throw IllegalStateException("api.php 预热挂了")
},
clock = now::get,
log = { logs += it },
)
}
// ── ① 预热不阻塞 ──
@Test
fun kickNeverBlocksCaller() {
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
// 预热第一步就卡住:调用方必须**立刻**返回,否则 UI 线程会被拖住。
val preheat = Preheat(
hosts = { listOf("http://a.example") },
runOn = { runnable -> Thread(runnable, "preheat-test").start() },
warmJsSession = {
entered.countDown()
release.await(5, TimeUnit.SECONDS)
true
},
warmUpstreamToken = {}, warmTransport = {}, warmAppToken = {}, warmSourceConfig = {},
log = {},
)
val startedAt = System.nanoTime()
val kicked = preheat.kick(Preheat.REASON_STARTUP)
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000L
assertTrue("kick 应把任务交给后台", kicked)
assertTrue("kick 必须立即返回(实测 ${elapsedMs}ms)", elapsedMs < 300)
assertTrue("预热确实在后台跑", entered.await(3, TimeUnit.SECONDS))
assertTrue("后台在跑时 isRunning 应为 true", preheat.isRunning())
release.countDown()
val deadline = System.currentTimeMillis() + 5_000L
while (preheat.isRunning() && System.currentTimeMillis() < deadline) Thread.sleep(20)
assertFalse("跑完应复位(否则后续预热永远被单飞挡住)", preheat.isRunning())
}
// ── ② 同一时刻只发一次(单飞) ──
@Test
fun kickRunsAtMostOneRoundAtATime() {
val release = CountDownLatch(1)
val entered = CountDownLatch(1)
val preheat = Preheat(
hosts = { listOf("http://a.example") },
runOn = { runnable -> Thread(runnable, "preheat-test").start() },
warmJsSession = {
entered.countDown()
release.await(5, TimeUnit.SECONDS)
true
},
warmUpstreamToken = {}, warmTransport = {}, warmAppToken = {}, warmSourceConfig = {},
log = {},
)
assertTrue(preheat.kick(Preheat.REASON_STARTUP))
// 换一个 reason 也一样:单飞是**全局**的,否则并发预热会对源站叠加请求。
assertFalse("已有预热在跑时,第二次 kick 必须被拒", preheat.kick(Preheat.REASON_RESUME))
assertFalse(preheat.kick(Preheat.REASON_SONG_LIST))
assertTrue("后台确实开始跑了", entered.await(3, TimeUnit.SECONDS))
assertEquals("真的只跑了一轮", 1, preheat.roundCount())
release.countDown()
val deadline = System.currentTimeMillis() + 5_000L
while (preheat.isRunning() && System.currentTimeMillis() < deadline) Thread.sleep(20)
assertEquals(1, preheat.roundCount())
}
// ── 同一 reason 节流 / 不同 reason 各自计时 ──
@Test
fun sameReasonIsThrottledButDifferentReasonIsNot() {
val f = Fixture()
// 同步执行器:kick 里就把这一轮跑完,便于用假时钟断言节流。
val preheat = f.preheat(listOf("http://a.example"), { it.run() }, jsWarms = true, throwEverything = false)
assertTrue(preheat.kick(Preheat.REASON_STARTUP))
assertEquals(1, preheat.roundCount())
assertFalse("同一 reason 在节流窗口内不得重复", preheat.kick(Preheat.REASON_STARTUP))
assertEquals(1, preheat.roundCount())
// 另一个触发原因有自己的桶(进入点歌列表 / 回前台是不同事件)。
assertTrue(preheat.kick(Preheat.REASON_SONG_LIST))
assertEquals(2, preheat.roundCount())
// 节流窗口过去后,同一 reason 可以再来一轮。
f.now.addAndGet(Preheat.DEFAULT_MIN_INTERVAL_MS + 1)
assertTrue(preheat.kick(Preheat.REASON_STARTUP))
assertEquals(3, preheat.roundCount())
}
// ── ③ 失败只写本地日志、绝不影响点歌 ──
@Test
fun failuresOnlyGoToLogAndNeverThrow() {
val f = Fixture()
// 每一步(JS 会话 / i.php / 传输 / App 令牌 / api.php)都抛异常:kick 绝不能外抛。
val preheat = f.preheat(
listOf("http://a.example", "https://b.example"),
{ it.run() }, jsWarms = false, throwEverything = true,
)
val kicked = preheat.kick(Preheat.REASON_STARTUP)
assertTrue("调度本身是成功的(失败发生在步骤里)", kicked)
assertFalse("跑完必须复位 running", preheat.isRunning())
// hosts() 也抛异常时同样只能记日志。
val hostsThrow = f.preheat(listOf("http://a.example"), { it.run() }, jsWarms = false, throwEverything = true)
f.now.addAndGet(Preheat.DEFAULT_MIN_INTERVAL_MS + 1)
assertTrue(hostsThrow.kick(Preheat.REASON_RESUME))
val joined = f.logs.joinToString("\n")
assertTrue("失败必须留下本地日志(含 hosts() 异常那一轮)", joined.contains("PREHEAT 跳过"))
assertFalse("running 不得残留", preheat.isRunning() || hostsThrow.isRunning())
}
@Test
fun stepFailuresAreLoggedWithStepName() {
val f = Fixture()
val preheat = Preheat(
hosts = { listOf("http://a.example") },
runOn = { it.run() },
warmJsSession = { throw IllegalStateException("js 预热挂了") },
warmUpstreamToken = { throw IllegalStateException("i.php 挂了") },
warmTransport = { throw IllegalStateException("传输挂了") },
warmAppToken = { throw IllegalStateException("token 挂了") },
warmSourceConfig = { throw IllegalStateException("api.php 挂了") },
clock = f.now::get,
log = { f.logs += it },
)
preheat.kick(Preheat.REASON_STARTUP)
val joined = f.logs.joinToString("\n")
assertTrue(joined.contains("PREHEAT 步骤失败"))
for (step in listOf("步骤=js-session", "步骤=source-config", "步骤=upstream-token", "步骤=transport", "步骤=app-token")) {
assertTrue("日志里要能看出是哪一步失败:$step", joined.contains(step))
}
assertFalse("失败不能影响 running 复位", preheat.isRunning())
}
@Test
fun failureDoesNotAffectLaterRounds() {
val f = Fixture()
val preheat = f.preheat(listOf("http://a.example"), { it.run() }, jsWarms = false, throwEverything = true)
assertTrue(preheat.kick(Preheat.REASON_STARTUP))
// 失败一轮之后,下一次触发(窗口外)照旧能跑 —— 预热是尽力而为,不是一次性闸门。
f.now.addAndGet(Preheat.DEFAULT_MIN_INTERVAL_MS + 1)
assertTrue(preheat.kick(Preheat.REASON_STARTUP))
assertEquals(2, preheat.roundCount())
assertFalse(preheat.isRunning())
}
@Test
fun executorRejectionIsSwallowed() {
val f = Fixture()
val preheat = Preheat(
hosts = { listOf("http://a.example") },
runOn = { throw RejectedExecutionException("池满了") },
warmJsSession = { true },
warmUpstreamToken = {}, warmTransport = {}, warmAppToken = {}, warmSourceConfig = {},
clock = f.now::get,
log = { f.logs += it },
)
assertFalse("执行器拒绝时返回 false", preheat.kick(Preheat.REASON_STARTUP))
assertTrue("必须记日志", f.logs.joinToString("\n").contains("PREHEAT 调度失败"))
assertFalse("running 必须复位,否则以后再也预热不了", preheat.isRunning())
}
// ── ④ 步骤顺序 / 覆盖范围 ──
@Test
fun jsSessionWarmSkipsDuplicateUpstreamRequests() {
val f = Fixture()
val preheat = f.preheat(
listOf("http://official.example", "https://self.example"),
{ it.run() }, jsWarms = true, throwEverything = false,
)
preheat.kick(Preheat.REASON_STARTUP)
assertFalse(
"JS 会话预热已发请求时,不应再打一遍原生侧 /i.php",
f.calls.contains("upstream:http://official.example"),
)
assertTrue(
"但传输预热与 App 令牌仍要覆盖每个源",
f.calls.contains("transport:http://official.example") &&
f.calls.contains("transport:https://self.example") &&
f.calls.contains("token:https://self.example"),
)
assertEquals("api.php 配置每轮只预热一次", 1, f.calls.count { it == "config" })
}
@Test
fun kotlinUpstreamTokenIsWarmedWhenJsBridgeIsNotReady() {
val f = Fixture()
val preheat = f.preheat(
listOf("http://official.example", "https://self.example"),
{ it.run() }, jsWarms = false, throwEverything = false,
)
preheat.kick(Preheat.REASON_STARTUP)
assertTrue(
"桥不可用(老盒子)时必须热原生侧的 /i.php 缓存",
f.calls.contains("upstream:http://official.example") &&
f.calls.contains("upstream:https://self.example"),
)
}
@Test
fun warmsAtMostThreeHostsInOrder() {
val f = Fixture()
val preheat = f.preheat(
listOf("http://h1.example", "http://h2.example", "http://h3.example", "http://h4.example"),
{ it.run() }, jsWarms = false, throwEverything = false,
)
preheat.kick(Preheat.REASON_STARTUP)
assertTrue(f.calls.contains("upstream:http://h3.example"))
assertFalse(
"第四个源不再预热(防止源列表很长时一次打出太多请求)",
f.calls.contains("upstream:http://h4.example"),
)
assertEquals(
"JS 会话预热拿到的是按顺序截断后的列表",
"js:http://h1.example|http://h2.example|http://h3.example",
f.calls.first(),
)
}
@Test
fun emptyHostListIsSkippedQuietly() {
val f = Fixture()
val preheat = f.preheat(emptyList(), { it.run() }, jsWarms = false, throwEverything = false)
assertTrue(preheat.kick(Preheat.REASON_STARTUP))
assertTrue(f.logs.joinToString("\n").contains("没有可用源"))
assertEquals(0, f.calls.size)
}
@Test
fun logLineTellsWhyAndHowLong() {
val f = Fixture()
val preheat = f.preheat(listOf("http://a.example"), { it.run() }, jsWarms = true, throwEverything = false)
preheat.kick(Preheat.REASON_SONG_LIST)
val joined = f.logs.joinToString("\n")
assertTrue("完成行要带触发原因与耗时", joined.contains("PREHEAT 完成 reason=song-list"))
assertTrue(joined.contains("用时="))
}
}
@@ -0,0 +1,98 @@
package com.local.ktv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「**首次尝试零等待、退避只作用于重试**」的静态锁(`assets/mobile/ktv_api.js`)。
*
* 为什么用文本断言:这是 JS 侧的行为,纯 JVM 单测跑不了它(Android 上由隐藏 WebView 执行)。
* 但本轮用户报的"点歌后 3~5 秒"正是这里的一条 `await sleep(3000)`,必须在 CI 里被钉住:
* 一旦有人把退避重新挪回首次请求之前、或给终态失败(403/404/4xx)加回退避,这个测试就红。
*
* 同一份脚本另有 Node stub 对照测量(`tmp/measure_ktv_js.js`,优化前 4064ms → 优化后 903ms)。
*/
class SongFetchRetryPolicyTest {
private val js: String = RepoFiles.ktvApiJs()
/** 取址主流程里"真正发出 do.php 请求"的那一行(首次尝试)。 */
private val firstAttemptLine = "var data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders);"
@Test
fun oldThreeSecondBackoffIsGone() {
// ⚠️ 只匹配**可执行代码**:注释里会提到旧的 sleep(3000)(那是说明,不是行为)。
val code = js.lines().filterNot { it.trim().startsWith("//") || it.trim().startsWith("*") }.joinToString("\n")
assertFalse(
"旧的固定 sleep(3000) 必须从可执行代码里彻底消失(它就是用户实测的 3 秒白等)",
code.contains("sleep(3000)"),
)
assertTrue("退避时长必须是具名常量,便于核对与调整", js.contains("const RETRY_BACKOFF_MS ="))
}
@Test
fun backoffIsUsedOnlyBeforeARetry() {
// 全脚本只允许一处 await sleep(...):重试之前。
assertEquals(
"退避只允许出现在重试路径上(一处)",
1,
Regex("await\\s+sleep\\(").findAll(js).count(),
)
val firstAttemptAt = js.indexOf(firstAttemptLine)
val riskControlAware = js.indexOf("retry once with same token after")
val sleepAt = js.indexOf("await sleep(RETRY_BACKOFF_MS)")
assertTrue("找不到首次尝试那一行(脚本结构变了?)", firstAttemptAt > 0)
assertTrue(firstAttemptAt < sleepAt)
assertTrue("退避必须发生在首次尝试之后", riskControlAware in 1 until sleepAt)
}
@Test
fun terminalFailuresDoNotRetry() {
assertTrue("必须有统一的终态判定函数", js.contains("function isTerminalFailure("))
// 4xx / 业务码 403 / 404
val fn = js.substring(js.indexOf("function isTerminalFailure("), js.indexOf("function isTerminalFailure(") + 700)
assertTrue("HTTP 4xx 属于终态", fn.contains("status >= 400 && status < 500"))
assertTrue("业务码 403(风控真实形态)属于终态", fn.contains("code === 403"))
assertTrue("业务码 404(该源没有这首)属于终态", fn.contains("code === 404"))
// 终态分支里不能再出现"发请求"或"sleep"。
val branchStart = js.indexOf("else if (isTerminalFailure(data.httpStatus, data.code)) {")
assertTrue("终态分支必须存在", branchStart > 0)
val branchEnd = js.indexOf("} else {", branchStart)
assertTrue(branchEnd > branchStart)
val branch = js.substring(branchStart, branchEnd)
assertFalse("终态失败不得退避", branch.contains("sleep("))
assertFalse("终态失败不得重试", branch.contains("_fetchSongUrl("))
assertTrue("终态失败要明确换下一个源", branch.contains("换下一个源"))
}
@Test
fun adAndTransientFailuresStillRetryOnce() {
val branchStart = js.indexOf("} else {", js.indexOf("else if (isTerminalFailure(data.httpStatus, data.code)) {"))
val branchEnd = js.indexOf("if (data.code === 200) {", branchStart)
val branch = js.substring(branchStart, branchEnd)
assertTrue("广告/错号资源仍要退避后重试一次", branch.contains("await sleep(RETRY_BACKOFF_MS)"))
assertEquals("只重试一次", 1, Regex("_fetchSongUrl\\(").findAll(branch).count())
}
@Test
fun tokenRejectionRetriesImmediatelyWithoutBackoff() {
val start = js.indexOf("if (tokenRejected) {")
val end = js.indexOf("} else if (isTerminalFailure(", start)
assertTrue(start > 0 && end > start)
val branch = js.substring(start, end)
assertTrue("令牌被拒后要立刻重试一次", branch.contains("_fetchSongUrl("))
assertFalse("令牌被拒不该退避(那是留给广告/5xx 的)", branch.contains("sleep("))
}
@Test
fun stageTimingHookIsPresentAndLocalOnly() {
assertTrue("JS 侧要有 _timing() 分阶段日志", js.contains("_timing: function(stage, host)"))
assertTrue("计时日志走 android.log(→ 只写本地 ktv.log)", js.contains("global.android.log('FETCH-TIME "))
assertTrue("首次 i.php 请求发出要能计时", js.contains("self._timing('点歌→i.php请求发出'"))
assertTrue("do.php 请求发出要能计时", js.contains("this._timing('点歌→do.php请求发出'"))
}
}
@@ -89,14 +89,17 @@ public class SourceOrderConsistencyTest {
*/
@Test
public void bridgeApiMarkerIsConsistent() throws IOException {
// 取址协议/缓存策略一变就要提升这个数字(设备端靠它判断要不要丢弃缓存的旧脚本)。
// 16 = 点歌延迟优化(终态失败不退避 + 分阶段耗时日志)。
final int bridgeApi = 16;
String js = RepoFiles.ktvApiJs();
assertTrue("ktv_api.js 缺少 KTV_BRIDGE_API: 15 注释标记",
js.contains("KTV_BRIDGE_API: 15"));
assertTrue("ktv_api.js 导出的版本号应为 15",
js.contains("global.KtvBridgeApiVersion = 15"));
assertTrue("KtvJsBridge 的校验标记应为 15",
RepoFiles.ktvJsBridgeKt().contains("BRIDGE_API_MARKER = \"KTV_BRIDGE_API: 15\""));
assertTrue("AGENTS.md 里的 KTV_BRIDGE_API 记录必须与代码一致(15)",
RepoFiles.agentsMd().contains("已升到 **15**"));
assertTrue("ktv_api.js 缺少 KTV_BRIDGE_API: " + bridgeApi + " 注释标记",
js.contains("KTV_BRIDGE_API: " + bridgeApi));
assertTrue("ktv_api.js 导出的版本号应为 " + bridgeApi,
js.contains("global.KtvBridgeApiVersion = " + bridgeApi));
assertTrue("KtvJsBridge 的校验标记应为 " + bridgeApi,
RepoFiles.ktvJsBridgeKt().contains("BRIDGE_API_MARKER = \"KTV_BRIDGE_API: " + bridgeApi + "\""));
assertTrue("AGENTS.md 里的 KTV_BRIDGE_API 记录必须与代码一致(" + bridgeApi + ")",
RepoFiles.agentsMd().contains("已升到 **" + bridgeApi + "**"));
}
}
@@ -0,0 +1,231 @@
package com.local.ktv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
/**
* 上游 `/i.php` token 缓存([SourceTokenCache])单测。
*
* 覆盖需求点名的三件事:
* ① **命中缓存不重复请求**(旧实现每首歌、每个源都重打一次 `/i.php`);
* ② TTL 按源站自己的语义(`heart_time`),拿不到用 90 分钟安全默认;
* ③ **同一 host 同时只发一次**(单飞),且**不同 host 互不阻塞**。
*/
class SourceTokenCacheTest {
private class FakeSource {
val now = AtomicLong(1_700_000_000_000L)
val loads = AtomicInteger(0)
val loadedHosts: MutableList<String> = Collections.synchronizedList(mutableListOf())
@Volatile var heartTimeSec: Long? = 5400L
@Volatile var token: String = "tok-1"
@Volatile var fail = false
/** 每次"请求"的阻塞时间(模拟一次真实往返)。 */
@Volatile var delayMs = 0L
@Volatile var gate: CountDownLatch? = null
fun cache(): SourceTokenCache = SourceTokenCache(load = { host ->
loads.incrementAndGet()
loadedHosts += host
gate?.await(5, TimeUnit.SECONDS)
if (delayMs > 0) Thread.sleep(delayMs)
if (fail) null else UpstreamToken(token, heartTimeSec)
}, clock = now::get)
}
// ── ① 命中缓存不重复请求 ──
@Test
fun cachesTokenAndDoesNotRequestAgain() {
val src = FakeSource()
val cache = src.cache()
repeat(3) {
assertEquals("tok-1", cache.tokenFor("http://zk.ktv1.top:6677"))
}
assertEquals("三次取令牌只允许打一次 /i.php", 1, src.loads.get())
assertTrue(cache.hasFreshToken("http://zk.ktv1.top:6677"))
}
@Test
fun differentHostsHaveIndependentCacheEntries() {
val src = FakeSource()
val cache = src.cache()
src.token = "tok-official"
assertEquals("tok-official", cache.tokenFor("http://a.example"))
src.token = "tok-self"
assertEquals("tok-self", cache.tokenFor("https://b.example"))
// 各自缓存,重复取都命中。
assertEquals("tok-official", cache.tokenFor("http://a.example"))
assertEquals("tok-self", cache.tokenFor("https://b.example"))
assertEquals(2, src.loads.get())
}
@Test
fun trailingSlashAndWhitespaceShareTheSameEntry() {
val src = FakeSource()
val cache = src.cache()
assertEquals("tok-1", cache.tokenFor("http://a.example/"))
assertEquals("tok-1", cache.tokenFor(" http://a.example "))
assertEquals(1, src.loads.get())
}
// ── ② TTL 语义 ──
@Test
fun ttlComesFromSourceHeartTime() {
val src = FakeSource()
val cache = src.cache()
src.heartTimeSec = 600L // 源站自报 10 分钟
cache.tokenFor("http://a.example")
src.now.addAndGet(599_000L)
assertTrue("heart_time 内必须命中缓存", cache.hasFreshToken("http://a.example"))
assertEquals(1, src.loads.get())
src.now.addAndGet(2_000L)
assertFalse("过了 heart_time 必须重新取", cache.hasFreshToken("http://a.example"))
cache.tokenFor("http://a.example")
assertEquals(2, src.loads.get())
}
@Test
fun missingHeartTimeFallsBackToNinetyMinutes() {
val src = FakeSource()
val cache = src.cache()
src.heartTimeSec = null
cache.tokenFor("http://a.example")
src.now.addAndGet(89L * 60L * 1000L)
assertEquals("拿不到 heart_time → 安全默认 90 分钟", 1, src.loads.get())
src.now.addAndGet(2L * 60L * 1000L)
cache.tokenFor("http://a.example")
assertEquals(2, src.loads.get())
}
@Test
fun absurdHeartTimeIsClamped() {
val src = FakeSource()
val cache = src.cache()
// 上游给出 0:不能变成"立刻过期"(那就退化成每首歌都打一次 /i.php)。
src.heartTimeSec = 0L
cache.tokenFor("http://a.example")
src.now.addAndGet(30_000L)
assertEquals("0 秒被夹到 TTL 下界(1 分钟)", 1, src.loads.get())
}
// ── ③ 单飞(同一 host 同时只发一次;不同 host 不互阻) ──
@Test
fun concurrentRequestsForSameHostHitTheSourceOnce() {
val src = FakeSource()
val cache = src.cache()
src.delayMs = 120L
val threads = 8
val start = CountDownLatch(1)
val done = CountDownLatch(threads)
val results: MutableList<String?> = Collections.synchronizedList(mutableListOf())
repeat(threads) {
Thread {
start.await()
results += cache.tokenFor("http://a.example")
done.countDown()
}.start()
}
start.countDown()
assertTrue("并发取令牌应在 5 秒内完成", done.await(5, TimeUnit.SECONDS))
assertEquals("同一 host 并发只能发一次 /i.php", 1, src.loads.get())
assertEquals(threads, results.size)
results.forEach { assertEquals("所有并发调用者拿到同一个 token", "tok-1", it) }
}
@Test
fun slowHostDoesNotBlockAnotherHost() {
val src = FakeSource()
val cache = src.cache()
val gate = CountDownLatch(1)
src.gate = gate
val slow = Thread { cache.tokenFor("http://slow.example") }
slow.start()
// 慢源还卡在请求里时,另一个源必须能立刻拿到自己的令牌。
Thread.sleep(80)
val startedAt = System.nanoTime()
src.gate = null
src.token = "tok-fast"
assertEquals("tok-fast", cache.tokenFor("http://fast.example"))
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000L
assertTrue("不同 host 之间不得互相阻塞(实测 ${elapsedMs}ms)", elapsedMs < 500)
gate.countDown()
slow.join(5_000)
assertEquals(2, src.loads.get())
}
// ── 失败与失效 ──
@Test
fun failureIsBackedOffThenRetried() {
val src = FakeSource()
val cache = src.cache()
src.fail = true
assertNull(cache.tokenFor("http://a.example"))
assertEquals(1, src.loads.get())
// 冷却期内不再打(否则每首歌都白打一次)。
assertNull(cache.tokenFor("http://a.example"))
assertEquals(1, src.loads.get())
src.now.addAndGet(SourceTokenCache.FAILURE_BACKOFF_MS + 1)
src.fail = false
assertEquals("tok-1", cache.tokenFor("http://a.example"))
assertEquals(2, src.loads.get())
}
@Test
fun loaderExceptionDegradesToNullWithoutThrowing() {
val cache = SourceTokenCache(load = { throw IllegalStateException("boom") })
assertNull("取 token 异常绝不能外抛(取址照常发,只是不带令牌)", cache.tokenFor("http://a.example"))
assertEquals(1, cache.fetchCount("http://a.example"))
}
@Test
fun invalidateForcesRefetch() {
val src = FakeSource()
val cache = src.cache()
assertEquals("tok-1", cache.tokenFor("http://a.example"))
cache.invalidate("http://a.example")
assertFalse(cache.hasFreshToken("http://a.example"))
src.token = "tok-2"
assertEquals("tok-2", cache.tokenFor("http://a.example"))
assertEquals(2, src.loads.get())
}
@Test
fun putPrefillsCacheWithoutRequest() {
val src = FakeSource()
val cache = src.cache()
cache.put("http://a.example", "tok-warm", 5400L)
assertEquals("tok-warm", cache.tokenFor("http://a.example"))
assertEquals("预热写入后不得再发请求", 0, src.loads.get())
}
@Test
fun blankHostIsIgnored() {
val src = FakeSource()
val cache = src.cache()
assertNull(cache.tokenFor(" "))
assertEquals(0, src.loads.get())
}
}