68 KiB
App 歌曲源获取流程(麦动KTV Android 端)
这段话给不看代码的人
用户点一首歌,App 先看本地有没有、没有就去"歌曲源"换一条下载地址。取址有两套实现: 隐藏 WebView 里的热更新脚本
ktv_api.js(主路径)和 Kotlin 的SongUrlResolver(原生兜底, 给 WebView 不可用的老盒子用)。两套协议完全一样:先GET <源>/i.php拿 token, 再GET <源>/music/do.php拿地址;多个源按顺序试、失败的源"沉到队尾"。 拿到的地址必须过一道可信闸门(AdVideoFilter.isTrustedSongUrl)——源站有时会返回广告视频 冒充歌曲(历史上真发生过,广告被当歌下载播放),所以地址里的.ts文件名必须等于点歌编号, 命中广告域名/特征一律拒收。拿到可信地址后:交给 okdownload 下载落盘 → 播放, 同时顺带把这条地址 POST 给自建服务器/push_download(省服务器一次上游取址)。 对接自建源时多一个GET <源>/token拿 App 令牌(请求头X-App-Token), 官方源不认这个接口(404/405)会被记住、之后不再尝试。全部源都失败时弹一句 「远程源已全部失效,等待新版本修复」(15 分钟节流)。
本文所有函数名、参数名、默认值、行号均从当前工作区代码逐条核对;无法确认的地方显式标注「(未在代码中确认)」。
核对基准:maidong-ktv/app/src/main/(versionName 1.6.3 / versionCode 46,见 app/build.gradle)。
2026-10-15(V1.6.3/46)更新说明 —— 点歌延迟优化 本次只重新核对了与"点歌延迟"直接相关的章节(§0 文件索引、§3.3 JS 重试语义、§4 令牌、 §5.1
heart_time、§13 新增的《点歌延迟优化》);其余章节沿用上一版(1.6.0/43 基准)的 核对结果,行号可能已漂移,引用前请以代码为准。 改动见 §13:JS 终态失败不再退避重试、上游/i.phptoken 按 host 缓存(TTL=heart_time)、 App 令牌按 host 单飞、SourcePreheat后台预热、SongFetchTrace分阶段耗时日志、api.php移出取址关键路径。
0. 代码索引(本文引用到的文件)
| 文件 | 角色 |
|---|---|
java/com/local/ktv/SongApiClient.kt |
四条取址路径的唯一汇合点 + 点歌推送切入点 + 主线程保护 |
java/com/local/ktv/SongUrlResolver.kt |
原生兜底取址(复刻 JS 协议,/i.php → /music/do.php) |
java/com/local/ktv/KtvJsBridge.kt |
隐藏 WebView 宿主 + 裸 Socket「干净请求」通道 + 地址闸门第二处 |
assets/mobile/ktv_api.js |
JS 主取址路径(可热更新),协议第 4 处闸门 |
java/com/local/ktv/AppToken.kt |
App 令牌纯逻辑(取/缓存/不支持记忆/401 只重试一次) |
java/com/local/ktv/AppTokenClient.kt |
令牌的 Android 胶水(接到干净 GET 通道) |
java/com/local/ktv/SourceHosts.kt |
源列表 / 媒体允许清单的归一化(纯逻辑) |
java/com/local/ktv/AdVideoFilter.kt |
地址可信性唯一闸门 isTrustedSongUrl |
java/com/local/ktv/PushDownload.kt |
点歌推送纯逻辑(端点/表单/去重) |
java/com/local/ktv/PushDownloadClient.kt |
点歌推送 Android 胶水(后台线程 + 8s 超时) |
java/com/local/ktv/KtvStore.kt |
设置持久化(state.json:sourceHosts/allowedMediaHosts/pushServerUrl/pushToken/deviceIdentityJson) |
java/com/local/ktv/DeviceIdentity.kt |
假盒子身份(mac / sn,生成一次固定不变) |
java/com/local/ktv/Whoami.kt |
POST hbeat.ktvsky.com/whoami 心跳(24 字段,纯逻辑) |
java/com/local/ktv/MainActivity.kt |
点歌入口、启动初始化、设置页、网络恢复、心跳调度 |
java/com/local/ktv/SongOkDownloadManager.kt |
下载层(含最终兜底闸门、重试、落盘校验) |
java/com/local/ktv/Song.kt |
Song.getDownloadUrl()(曲库条目取址入口之一) |
java/com/local/ktv/Eac19WindowGuard.kt |
全部源失效的用户可见提示(15 分钟节流) |
java/com/local/ktv/MiliLog.kt |
远程日志(GlogCenter)+ 本地 logs/ktv.log |
java/com/local/ktv/SourceTokenCache.kt |
上游 /i.php token 的按 host 缓存(TTL=heart_time、单飞、纯 JVM) |
java/com/local/ktv/Preheat.kt / SourcePreheat.kt |
取址预热纯逻辑(节流/单飞/步骤编排)+ Android 接线(JS 会话·i.php·api.php·DNS/TCP/TLS·/token) |
java/com/local/ktv/SongFetchTrace.kt |
取址链路的分阶段耗时日志(FETCH-TIME …,只写本地) |
java/com/local/ktv/SongFileValidator.kt |
内容校验(≥6MB / ≥60s / TS 包完整)——对广告无效 |
AndroidManifest.xml |
usesCleartextTraffic="true" + networkSecurityConfig(http:// 源可用) |
1. 一图流
flowchart TD
A["点歌入口<br/>MainActivity.orderSong:4930 → handleSong:4979<br/>扫码/远程 order:3573 → handleSong"] --> B["addToQueue:10382<br/>本地已就绪? → addReadySongToQueue:10403 → play:5145"]
B --> C["下载: downloadSong:5505<br/>→ SongOkDownloadManager.download:156(io 线程)"]
C --> D["buildDownloadUrl:248<br/>① 校验 song.downloadUrl 缓存:256<br/>② SongApiClient.getSongDownloadUrl():269"]
D --> E{"SongApiClient<br/>resolveSongDownloadUrl:82"}
E -->|"主线程?"| F["fetchFromDb:146(仅 DB,禁网络)"]
E -->|"桥不可用"| G["resolveNatively:140<br/>SongUrlResolver.resolve:218"]
E -->|"桥就绪"| H["bridge.getSongUrl:286<br/>等 15s:102 / 185s:122"]
H -->|"JS 返回地址"| I["complete:391<br/>AdVideoFilter.resolve:210 + isTrustedDownloadUrl:368"]
I -->|"null"| G
H -->|"null/超时"| G
G -->|"null"| F
F -->|"null"| J["返回空 → 「无法生成下载地址」"]
I -->|"url"| K
G -->|"url(先过 isValidSongUrl:433)"| K
F -->|"url(先过 isLikelyDemoUrl:171 + isTrustedSongUrl:153)"| K
K["SongApiClient 4 参入口:72<br/>PushDownloadClient.offer:33(fire-and-forget)"] --> L["buildDownloadUrl 最终闸门:276<br/>AdVideoFilter.isTrustedSongUrl"]
L -->|"不可信"| M["purgeCachedSongFile:301 + 上报「地址不可信」"]
L -->|"可信"| N["startDownloadTask:317 → okdownload → finalizeDownload:403<br/>SongFileValidator.inspect:432 → 落盘"]
N --> O["onDownloadComplete → playLocalFile:5191 → 播放"]
M --> P["Eac19WindowGuard.noteAllSourcesFailed:318 → onAllFailed 弹窗"]
J --> P
1.1 分步表(点歌 → 取址 → 校验 → 播放/下载 → 推送上报)
| # | 步骤 | 函数 | 文件:行 |
|---|---|---|---|
| 1 | 点歌(本地 UI) | orderSong() → handleSong() |
MainActivity.kt:4930 / :4979 |
| 2 | 点歌(远程/扫码 HTTP order) |
order(query) → main.post { handleSong(song) } |
MainActivity.kt:3573 |
| 3 | 入队(未就绪先下载) | addToQueue() → downloadSong() |
MainActivity.kt:10382 / :10394 |
| 4 | 启动下载 | play() → startDownloadAndPlay() → downloadSong() |
MainActivity.kt:5145 / :5327 / :5505 |
| 5 | 下载管理器入口 | SongOkDownloadManager.download() |
SongOkDownloadManager.kt:156 |
| 6 | 组装地址(先查缓存地址 + 最终闸门) | buildDownloadUrl() |
SongOkDownloadManager.kt:248 |
| 7 | 四路径汇合点 | SongApiClient.getSongDownloadUrl(4 参) |
SongApiClient.kt:72 |
| 8 | 取址实现(分流) | resolveSongDownloadUrl() |
SongApiClient.kt:82 |
| 9 | JS 主路径 | KtvJsBridge.getSongUrl() → ktv_api.js#getSongUrl |
KtvJsBridge.kt:286 / ktv_api.js:427 |
| 10 | 原生兜底路径 | SongUrlResolver.resolve() |
SongUrlResolver.kt:218 |
| 11 | DB 回落 / 主线程 DB 兜底 | fetchFromDb() |
SongApiClient.kt:146 / 调用点 :91 |
| 12 | 令牌(自建源) | AppTokenClient.withToken() → AppTokenSession.withToken() |
AppTokenClient.kt:57 / AppToken.kt:195 |
| 13 | 地址可信闸门(原生/JS/DB) | AdVideoFilter.isTrustedSongUrl() |
AdVideoFilter.kt:126(2 参)/ :131(3 参) |
| 14 | 点歌推送 | PushDownloadClient.offer() → submit() → post() |
PushDownloadClient.kt:33 / PushDownload.kt:135 / PushDownloadClient.kt:75 |
| 15 | 最终兜底闸门 + 上报 | buildDownloadUrl() 内联判定 |
SongOkDownloadManager.kt:276 / :284 |
| 16 | 落盘 + 内容校验 | startDownloadTask() → finalizeDownload() |
SongOkDownloadManager.kt:317 / :403(inspect 在 :432) |
| 17 | 失败提示 | Eac19WindowGuard.noteAllSourcesFailed() |
SongUrlResolver.kt:318 → Eac19WindowGuard.kt:73 |
2. 入口与四条取址路径
2.1 点歌入口(谁最终会去取址)
| 入口 | 位置 | 说明 |
|---|---|---|
| 主界面/搜索/排行/收藏/歌单点击 | handleSong() MainActivity.kt:4979(orderSong() :4930 是外部组件用的公共封装) |
全部收敛到 addToQueue() :10382 |
| 远程点歌页(手机扫码) | MainActivity.kt:3573(override fun order(query) 里 main.post { handleSong(song) }) |
走 LocalRemoteServer 的 order 回调 |
| 已点列表点击播放 | playSong() :4968 → play() :5145 |
已就绪直接播,否则下载 |
| 曲库条目取址(另一条独立入口) | Song.getDownloadUrl() Song.kt:76,内部调用 SongApiClient.getSongDownloadUrl(..., song.title) :80-82 |
直接命中汇合点(不经过 SongOkDownloadManager) |
| 备用/历史路径 | MainActivity.download(song) :4984 |
直接用 song.downloadUrl/videoUrl 下载,不取址、不过闸门(见 §11 注意事项) |
启动时装配:SongApiClient.init(this) MainActivity.kt:577,内部 KtvJsBridge(context) +
SongUrlResolver.install { url, appToken -> bridge?.rawCleanGet(url, appToken) }(SongApiClient.kt:30)+
SongUrlResolver.installPost { ... }(:32)+ AppTokenClient.install { ... }(:34)。
2.2 四条取址路径
| 路径 | 进入条件 | 关键函数 | 校验位置 | 失败后 |
|---|---|---|---|---|
| ① JS 桥路径 | 非主线程 && bridge != null && !bridge.isUnavailable(),等待 isReady() 最多 15 秒(SongApiClient.kt:102),并用 CountDownLatch 等 185 秒(:122) |
KtvJsBridge.getSongUrl :286 → JS KtvApi.getSongUrl ktv_api.js:427 |
JS isDemoUrl ktv_api.js:174;回来后再过 KtvJsBridge.complete :391(AdVideoFilter.resolve + isTrustedDownloadUrl) |
返回 null/超时 → 原生兜底(:128) |
| ② 原生兜底路径 | 桥为 null(SongApiClient.kt:93)、桥已判不可用(:96-98)、15s 未就绪(:109-116)、JS 取不到(:128) |
resolveNatively() :140 → SongUrlResolver.resolve() :218 |
SongUrlResolver.isValidSongUrl :433 → AdVideoFilter.isTrustedSongUrl |
返回 null → DB 回落 |
| ③ DB 回落路径 | 前两条都没给地址(?: 链的最后一环) |
fetchFromDb() :146(MuseDatabase().getCloudUrl(musicno)) |
!isLikelyDemoUrl(url)(:171)且 AdVideoFilter.isTrustedSongUrl(url, musicno)(:153) |
返回 null → 整个取址失败 |
| ④ 主线程 DB 兜底 | Looper.myLooper() == Looper.getMainLooper()(:89)——主线程只允许走本地 DB |
fetchFromDb() :91 |
同 ③ | 返回 null(主线程绝不发起网络取址,避免 ANR) |
备用第 5 条:
Song.getDownloadUrl()在最后还有一条无校验的兜底https://pub.cdn.cherryonline.cn/video/cloud-song/<filename>(Song.kt:86-88), 仅在前三条全部失败时返回。它不经过AdVideoFilter。
2.3 汇合点为什么在 SongApiClient.getSongDownloadUrl()
代码里给出的理由(SongApiClient.kt:59-70、SongOkDownloadManager.kt:267-269):
- 四条路径都在它下面:①②③④ 的实现全部在私有
resolveSongDownloadUrl()(:82)内部串联, 因此只有这个函数能保证「只要真的拿到一条地址,就恰好推送一次」。 挂在 JS/原生各自的成功分支必然漏掉某条路径 —— 广告闸门历史上就是这么漏的。 - 推送是旁路:
PushDownloadClient.offer()(:33)在返回之前 fire-and-forget, 不改写返回值、不影响播放(:74-78)。 - 主线程保护:它同时也是"主线程禁止网络 I/O"的判定点(
:89-92)。 - 注意它是两道防线中的第一道:真正"下载地址可信"的最终判定在
SongOkDownloadManager.buildDownloadUrl(:276),因为song.downloadUrl缓存 (来自曲库/上次取址)也必须在用之前校验(:256-265)。
3. 源列表与选源
3.1 默认值(两处必须逐项一致)
| 常量 | 值 | 位置 |
|---|---|---|
SourceHosts.DEFAULT |
["https://mdmuse.kasugano.cn", "http://zk.ktv1.top:6677"] |
SourceHosts.kt:31-34 |
SongUrlResolver.DEFAULT_HOSTS |
同上(仅用于 hostOrder 初值与日志) |
SongUrlResolver.kt:62-65 |
ktv_api.js CONFIG.HOSTS |
同上 | ktv_api.js:29-32 |
ktv_api.js CONFIG.HOST |
"https://mdmuse.kasugano.cn" |
ktv_api.js:33(注意:该字段在 JS 里从未被读取,见 §11) |
SourceHosts.SELF_HOST |
"mdmuse.kasugano.cn" |
SourceHosts.kt:37 |
SourceHosts.SELF_HOST_LAN |
"192.168.100.232" |
SourceHosts.kt:45 |
SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS |
["md.kasugano.cn", "192.168.100.232"] |
SourceHosts.kt:54 |
约定:自建源优先、官方源兜底(SourceHosts.kt:13-20、SongUrlResolver.kt:52-61)。
3.2 设置项如何注入
| 设置键 | 默认 | 读 | 原生生效 | JS 生效 |
|---|---|---|---|---|
sourceHosts |
SourceHosts.DEFAULT |
KtvStore.sourceHosts KtvStore.kt:87、load() :156-157(经 SourceHosts.parseList 归一化,空 → DEFAULT) |
MainActivity.applySourceSettings() :9417 → SongUrlResolver.applySourceHosts() SongUrlResolver.kt:141(重排 hostOrder,:142-146) |
KtvJsBridge.getSongUrl 内 sourceHostsForJs() :256 → 覆写 window.KtvApiConfig.HOSTS/HOST :321-322,然后 new window.KtvApi(...) :325 |
allowedMediaHosts |
SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS |
KtvStore.allowedMediaHosts KtvStore.kt:96、load() :158-159 |
AdVideoFilter.setAllowedMediaHosts() AdVideoFilter.kt:73(:9419 调用) |
覆写 window.KtvApiConfig.ALLOWED_MEDIA_HOSTS :323 |
pushServerUrl / pushToken |
"" / "local" |
KtvStore.kt:76 / :78、load() :153-154 |
— | —(推送用,见 §8) |
归一化规则(SourceHosts.parseList :68、normalizeHost :76):去空白、去尾斜杠、
只留 http:///https:// 开头、按出现顺序去重;空列表回落 DEFAULT。
允许清单另有一套归一化 normalizeAllowedHost :102(接受整条 URL 粘贴,抽出纯主机名、小写、去端口/路径)。
设置页分隔符:换行、逗号、分号、空白(SEPARATORS :57)。
设置页入口:showSourceHostsDialog() MainActivity.kt:9427(保存 → applySourceHostsText KtvStore.kt:265 →
saveState() → applySourceSettings() :9451)、showAllowedMediaHostsDialog() :9463。
⚠️ JS 侧只在下一次"重建会话"时才会拿到新列表:注入代码写在
if (!window.api) { ... }里 (KtvJsBridge.kt:314-331)。设置页保存只调applySourceSettings(),不会清window.api, 因此已建好的window.api.sourceOrder(ktv_api.js:284在构造时快照CONFIG.HOSTS)仍按旧顺序选源。 原生路径立刻生效。要让 JS 也立刻换源,需要SongApiClient.resetSourceSession()(SongApiClient.kt:194→KtvJsBridge.resetApiSession():275→ 置空window.api/_apiInitPromise,RESET_SESSION_JS:66-72)。目前调用点只有:断网/换网恢复MainActivity.kt:3830、 下载重试SongOkDownloadManager.kt:523(经clearTokenCache)、改设备身份MainActivity.kt:3774。
3.3 按顺序尝试 / 失败切换(两套实现同一语义)
原生(SongUrlResolver.resolve :218):
hosts = hostOrder 快照(:236)→ 按试用额度截断 take(maxHosts)(:240-244)
for host in hosts: // :250
token = fetchToken(host) // :251 → :324 GET /i.php
失败 → demote(host) 并 continue // :255-260
url = fetchSongUrl(host, token) // :261 → :340 GET /music/do.php
成功 && isValidSongUrl → promote(host); lastHost=host; blockedUntil=0; return url // :266-276
失败 → demote(host) // :288
demote(host):153:把该源移到队尾(只在不是最后一个时才动);promote(host):163:移到队首。hostOrder是Collections.synchronizedList(:68),进程内有效、不落盘(重启回默认顺序)。- JS 侧同名逻辑:
demoteSourcektv_api.js:297、promoteSource:307、this.sourceOrder:284。 - 一次取址最多试几个源:受
ensureConfig()的trial_times限制(SongUrlResolver.kt:240)。ensureConfig(:184)只对当前队首 POST<host>/api.php(干净 POST,KtvJsBridge.rawCleanPost:234), 缓存 30 分钟(CONFIG_TTL_MS:50),失败也写负缓存(:192,因为zk.ktv1.top:6677没有api.php)。 按当前默认顺序,队首是自建源(其api.php返回trial_times:0)→maxHosts == allHosts.size→ 不受限。 - JS 侧还有一层设备重试:
for deviceRound in 0..maxDeviceRetries(ktv_api.js:433), 但桥注入的是maxDeviceRetries: 0(KtvJsBridge.kt:325)→ 只跑一轮,不会再随机换设备身份。 - JS 重试/退避语义(V1.6.3 已改,见 §13):首次
do.php永远立即发出;失败后按isTerminalFailure(httpStatus, code)分类 —— 终态失败(HTTP 4xx / 业务码 403 / 404) 不退避、不重试,直接demoteSource换下一个源;只有广告/错号资源、HTTP 5xx、异常才退避RETRY_BACKOFF_MS(800ms;旧实现无条件sleep(3000))后重试一次。 令牌被拒(401 +令牌无效或已过期)仍是"清缓存 → 立即重试一次、不退避"。 ls取值:JS 只试['1'](ktv_api.js:451,注释说明其余值会被风控直接拒); 原生固定ls=1(SongUrlResolver.kt:351)。
3.4 切源对令牌缓存 / 「不支持令牌」记忆的影响
| 事件 | 令牌缓存 | 「不支持令牌」记忆 | 说明 |
|---|---|---|---|
| 换成另一个源 | 各源独立一个 key(AppTokenSession.key() AppToken.kt:209,按 host 归一化) |
同上,按 host 记 | 互不影响 |
resetApiSession()(JS 侧) |
不影响 Kotlin 的 AppTokenSession(它是 object 级进程内缓存);JS 侧只是把整个 window.api(连同它的 tokens,官方源 token,ktv_api.js:281/:284)置空丢弃(RESET_SESSION_JS KtvJsBridge.kt:66-72) |
不受影响 | 断网恢复后原生/JS 会重新"按需"用旧 App 令牌,直到它过期(提前 30s)或被 401 打回 |
| 401 令牌被拒 | invalidate(host) 清该源令牌 + 冷却计时 |
同时把 "不支持" 记忆一起清掉(安全网,AppToken.kt:169-170) |
会再试一次 /token |
| App 重启 | 全部丢失 | 全部丢失 | 都是内存结构(AppToken.kt:108-114) |
4. 令牌流程(App 令牌,自建源专用)
服务端契约(参考 selfhost-source/server/ktv/apptoken.py):
GET/POST /token → {"ok":true,"token":"<43 字符>","expires_in":600,…},Cache-Control: no-store;
受管路径默认 DEFAULT_PATHS = ("/music/do.php", "/push_download")(apptoken.py:63);
LEGACY_CHECK_PATHS = ("/i.php",)(:66);请求头名 X-App-Token(_presented() :414-432);
app_token_enforce 默认 False="带了就校验"(:206、config.py:81)。
4.1 常量与触发时机
| 常量 | 值 | 位置 |
|---|---|---|
AppToken.PATH |
"/token" |
AppToken.kt:25 |
AppToken.HEADER |
"X-App-Token" |
:28 |
AppToken.DEFAULT_EXPIRES_IN_SEC |
600 |
:31 |
AppToken.TIMEOUT_MS |
8000 |
:34 |
AppToken.EARLY_EXPIRE_MS |
30_000(提前 30 秒过期) |
:37 |
AppToken.FAILURE_BACKOFF_MS |
60_000(其它失败 60 秒冷却) |
:45 |
AppToken.UNAUTHORIZED_MARKER |
"令牌无效或已过期" |
:48 |
触发时机:只在一个地方 —— 调 /music/do.php 之前(另外由 SourcePreheat 在后台提前取一次,
见 §13:令牌 10 分钟有效,懒到第一次点歌会白等一次往返)。
原生 SongUrlResolver.fetchSongUrl :359:AppTokenClient.withToken(host) { appToken -> callEnvelope(channel, url, appToken) };
JS ktv_api.js:413-414:httpGet(..., appTokenHeader(host)),而 appTokenHeader(:203)回调 Kotlin
NativeBridge.appToken(host)(KtvJsBridge.kt:534)→ AppTokenClient.tokenFor(AppTokenClient.kt:39);
推送侧 PushDownloadClient.pushNow :61。tokenFor 内部先查缓存,未过期直接返回,不会每首歌都打 /token(AppToken.kt:129-131)。
4.2 状态机(AppTokenSession.tokenFor AppToken.kt:125-161)
| 情形 | 行为 | 行号 |
|---|---|---|
| 已记住"不支持" | 直接返回 null(连请求都不发) | :127 |
| 缓存未过期 | 复用 | :129-131 |
| 冷却中 | 返回 null(不带令牌、不发请求) | :133 |
GET <host>/token 网络异常/超时 |
冷却 60s,返回 null(请求照常发出,只是不带令牌) | :137-140 |
| HTTP 404 / 405 | 加入 unsupported → 永久(进程内)记住,返回 null |
:141-146 |
| 其它非 2xx(5xx/429…) | 冷却 60s | :147-150 |
2xx 但解析不出 token |
冷却 60s | :151-155 |
| 2xx 拿到 token | 缓存,TTL = (expires_in ?? 600)*1000 - 30000,最小 1 秒 |
:156-158 |
| 401 / body 含"令牌无效或已过期" | withToken 里 invalidate(host) → 重新 tokenFor → 再发一次请求,只重试一次 |
:195-201、判定 :204-207 |
关键细节:
- 按 host 单飞(V1.6.3):
tokenFor的网络 I/O 只在本 host 的门闩里做 —— 同一 host 并发调用只发一次/token,不同 host 互不阻塞(旧实现把网络 I/O 放在一把全局锁里, 一个慢源会把其它源的取令牌一起排队)。 isUnsupportedStatus(status)=status == 404 || status == 405(:56)。isUnauthorized(status, body)=status == 401 || body.contains("令牌无效或已过期")(:63-64)—— 有的反代把 401 包成 200,所以按文案兜底。- 重试语义:
withToken第二次调用的结果无论对错都直接返回(:200注释与实现)。 并且默认判定要求"本次确实带了令牌"才算令牌被拒(:206token != null && ...)—— 没带令牌的 401 属于源站自己的事,不当令牌问题处理。 - 失败一律降级为"不带令牌",绝不让取址失败在令牌上:
AppTokenClient.withToken的getOrElse { ...; request(null) }(AppTokenClient.kt:58-62)、tokenFor的runCatching(:40-42)。 - token 逻辑只在 Kotlin 一处(
AppTokenSession),JS 只是"问一次 + 带上头 + 被拒时让原生清缓存" (ktv_api.js:199-219)。
4.3 哪些请求带令牌
| 请求 | 带 X-App-Token? |
位置 |
|---|---|---|
GET /i.php |
不带(传 null)。注释:与官方源逐字节对齐,服务端把它列在 LEGACY_CHECK_PATHS,"带了就校验",我们没带 → 老形态完全不变 |
SongUrlResolver.fetchToken :330 走 getJson :408(内部 callEnvelope(channel, url, null) :409);JS getToken ktv_api.js:373 用 httpGet(url) 不传 extraHeaders |
GET /music/do.php |
带(自适应:先试 /token,官方源 404/405 会被记住后不再尝试) |
SongUrlResolver.kt:359;ktv_api.js:413-414 |
POST <push>/push_download |
带(仅当推送目标是自建源;取不到就按老形态推,服务端也认 body 里的 push_token) |
PushDownloadClient.kt:58-62、:86 |
POST /api.php(取配置) |
不带 | SongUrlResolver.ensureConfig :188 → rawCleanPost(url, form)(appToken 省略) |
whoami 心跳 |
不带(Content-Type: text/plain + 空 body) |
MainActivity.kt:3704-3712 |
GET /token 自身 |
不带(只发 Accept) |
AppTokenClient.install :29 → bridge.rawCleanGet(url, null, AppToken.TIMEOUT_MS) SongApiClient.kt:34 |
AppTokenClient.envelope 在 404/405 时只写一条本地日志
APP-TOKEN 源不支持令牌接口(HTTP $status),之后不再尝试 source=$url(:77-78)。
5. 协议细节(逐字核对)
5.1 GET <host>/i.php(取 token)
原生(SongUrlResolver.fetchToken :324-338):
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)
GET "$host/i.php?$params&sign=$sign"
JS(ktv_api.js:363-370)拼接顺序逐字节相同(:364-368 与上表一致)。
| 参数 | 值 / 来源 |
|---|---|
appid |
SongUrlResolver.APP_ID = d4eeacc6cec3434fbc8c41608a3056a0(:35)= ktv_api.js CONFIG.APP_ID :19 |
mac |
<mac>_<sn> —— DeviceIdentity.effective().mac(SongUrlResolver.kt:107)+ "_" + sn。JS 用 self.mac + '_' + self.sn(ktv_api.js:365) |
sn |
DeviceIdentity.effective().sn(SongUrlResolver.kt:105);JS 由桥注入 sn: DEVICE_SN(KtvJsBridge.kt:325) |
time |
秒级 Unix 时间戳:原生 System.currentTimeMillis()/1000(:325);JS Math.floor(Date.now()/1000)(:363) |
ver |
SongUrlResolver.VER = "2.0"(:38);JS 由桥覆写 KtvApiConfig.VER = '2.0'(KtvJsBridge.kt:318) |
vn |
SongUrlResolver.VN = "4.1.3.03281430"(:39);JS 由桥覆写 KtvApiConfig.VN = '4.1.3.03281430'(:319);与 Whoami.VN(Whoami.kt:40)同值 |
sign |
md5(params + APP_KEY);APP_KEY = 024210cba40d4385a93e6c2d3249bfb5(:36) |
| 请求头 | 只有 Accept: */*(原生 rawCleanGet KtvJsBridge.kt:220;JS httpGet ktv_api.js:234) |
返回(App 侧实际读取的字段):
| 字段 | App 用法 |
|---|---|
code |
!= 200 → 该源取 token 失败(SongUrlResolver.kt:333、ktv_api.js:376);== 403 时原生置 sawForbidden(:332) |
token |
json.optString("token")(:337)/ data.token(ktv_api.js:377) |
msg |
只用于本地日志(SongUrlResolver.kt:334) |
服务端还会返回
retry/ordertype/end_date/heart_time等字段 (selfhost-source/server/ktv/service.py:70-73)。 ⚠️ V1.6.3 起heart_time被消费:原生兜底路径把它当作/i.phptoken 的缓存时长 (秒;实测 5400 = 90 分钟,见SourceTokenCache)。retry/ordertype/end_date仍无消费方(语义未在 App 代码中确认)。
5.2 GET <host>/music/do.php(取播放地址)
原生(SongUrlResolver.fetchSongUrl :340-378):
val ts = System.currentTimeMillis() / 1000
val params = "appid=$APP_ID&device=${mac}_$sn&ish265=${if (h265) "1" else "0"}&ls=1" +
"&musicno=$musicno&resolution=$resolution&sn=$sn&time=$ts&token=$token"
val sign = md5(params + SDK_KEY)
GET "$host/music/do.php?$params&sign=$sign" // + 可选请求头 X-App-Token
JS(ktv_api.js:401-413)拼接顺序逐字节相同。
| 参数 | 值 / 来源 |
|---|---|
appid |
同 §5.1 |
device |
<mac>_<sn>(注意与 i.php 的 mac 参数同名不同键) |
ish265 |
"1" / "0";原生入参默认 h265 = false(SongUrlResolver.resolve :218),调用链传 false(SongOkDownloadManager.kt:269、MainActivity.kt:5239) |
ls |
恒为 "1"(原生 :351;JS lsValues = ['1'] :451) |
musicno |
点歌编号(SongOkDownloadManager.buildDownloadUrl 的 musicNo,:254) |
resolution |
调用链固定 "720"(SongOkDownloadManager.kt:269、MainActivity.kt:5239);resolve 默认参数也是 "720"(:218) |
sn |
同 §5.1 |
time |
秒级时间戳 |
token |
/i.php 返回的那个 token(源站 token,不是 App 令牌);原样拼接、不做 URL 编码(注释强调"原始 token 中的 ="必须保留,:350、ktv_api.js:400) |
sign |
md5(params + SDK_KEY);SDK_KEY = 19042303a8374f67ae3fe1e25c97936f(:37) |
| 额外请求头 | X-App-Token(自建源,可有可无) |
返回结构与字段:
| 字段 | 类型 | App 用法 |
|---|---|---|
code |
int | 200 = 成功;其它 = 该源失败。403 时原生置 sawForbidden(:373) |
msg |
string | 只进本地日志(:374);服务端成功时是 "SUCCESS"(service.py:89) |
data |
string | 媒体地址,takeIf { it.isNotEmpty() }(:377);JS 取 data.data(ktv_api.js:490) |
limit |
int | App 侧从不读取(全仓 grep limit 无命中)。服务端在成功时返回 "limit": 2000(service.py:89),语义未在 App 代码中确认 |
JS 侧还有额外约束:返回地址必须匹配 /^https?:\/\//i(ktv_api.js:492),
且 HTTP 非 200 时整体视为失败(_fetchSongUrl :415-417 返回 {code:null, data:'', httpStatus, body})。
5.3 mac / device / sn 的来源(假盒子身份)
- 统一来自
DeviceIdentity(Object 单例),三个请求(whoami / i.php / do.php)同源:KtvJsBridge.DEVICE_MAC:55/DEVICE_SN:56→ 注入 JS(:325、:329-330)SongUrlResolver.sn:105/mac:107MainActivity.sendDeviceHeartbeat用DeviceIdentity.effective().toFacts(pkn = store.whoamiPkn)(:3695-3697,toFacts见DeviceIdentity.kt:181-196)
- 生成规则(
DeviceIdentity.generate:329-357):随机挑一套真盒子模板(:84-120: Amlogic S905X/S905X3/S905X4、Rockchip RK3328、Realtek RTD1295)→mac = OUI + randomHex(6)(:331、newMac:422,OUI 是真实盒子厂商前缀)→sn = randomHex(12)(:337)→ 落盘进KtvStore.deviceIdentityJson(install:278-290、sink:281-286)。 - 生成一次后固定不变;只有设置页「重新生成设备身份」会换(
MainActivity.kt:3744/:3768)。 换身份会同时SongApiClient.clearTokenCache()(:3774)并补一次 whoami(:3775)。 validate():364-416校验自洽(12 位小写 hex、单播位、OUI 在白名单、_api与d_release对应、 fingerprint 形态…);不合法就重新生成(install:280)。- ⚠️ 这是构造值,不是网卡真实 MAC。真实网卡 MAC 由
DeviceMac解析,只用于本地日志 (MainActivity.kt:3737、日志行:3776-3779)。
5.4 签名算法
sign = md5( 参数字符串 + 密钥 ) // 直接字面拼接,无分隔符
/i.php:密钥APP_KEY = 024210cba40d4385a93e6c2d3249bfb5(SongUrlResolver.kt:36,ktv_api.js:20), 参数串不含 sign、顺序见 §5.1。/music/do.php:密钥SDK_KEY = 19042303a8374f67ae3fe1e25c97936f(:37,ktv_api.js:21)。md5实现:原生MessageDigest.getInstance("MD5")+"%02x"小写十六进制(SongUrlResolver.kt:436-438); JS 手写 MD5(ktv_api.js:51-107),同样输出小写 hex。- ⚠️
ktv_api.js里的CONFIG.VER/VN(4/4.100.5.11121504,:22-23)不是实际使用的值 —— 桥在构造KtvApi之前把它们覆写成2.0/4.1.3.03281430(KtvJsBridge.kt:318-319)。
5.5 传输通道(为什么必须"干净请求")
两套实现都走同一段裸 Socket(KtvJsBridge.NativeBridge.rawRequest :628-708):
- 请求行只发
Host+ 调用方指定的头(i.php/do.php只有Accept: */*), 不补 UA /Connection/Accept-Encoding(:627注释)。 - HTTPS 用
TlsCompat.sslSocketFactory()+createSocket(plain, host, port, true)(:648-651,先连接再包 SNI)。 - 连接超时显式设置
connect(InetSocketAddress(host, port), connectTimeout)(:646、:656-658),soTimeout = timeoutMs.coerceIn(1000, 30000)(:660)。 - 裸 Socket 失败(status 0)才回退
HttpURLConnection(request:580-589;回退实现httpRequest:593-625, 中性头:Go-http-client/1.1/Accept-Encoding: identity/Connection: close); 两个都失败才MiliLog.localError("NET-ERR ...")(:587)。 裸 Socket 异常打MiliLog.warn("RAW-ERR ...")(:700-703,这条进远程日志)。 - ⚠️ 这条通道不受
network_security_config与HttpsURLConnection默认工厂影响(注释:637-639)。
6. 地址可信性判定(唯一闸门 AdVideoFilter.isTrustedSongUrl)
6.1 规则清单(按判定顺序)
AdVideoFilter.isTrustedSongUrl(url, musicno, allowedHosts) :131-151:
| 序 | 判定 | 不通过时 | 行号 |
|---|---|---|---|
| 1 | url 非空 |
拒 | :132 |
| 2 | 必须以 http:// 或 https:// 开头 |
拒 | :133 |
| 3 | isAdVideo(url) == false(黑名单,优先级最高,命中允许清单也要拒) |
拒 | :134 |
| 4 | hostOf(url) 能取到 host |
拒 | :135 |
| 5 | 裸 IP(IP_HOST = ^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$,:200)且不在允许清单 |
拒 | :146 |
| 6 | pub-*.r2.dev + path 含 /ss/(R2_HOST :203,不受允许清单影响) |
拒 | :147 |
| 7 | path 里的 .ts 文件名主体必须等于 musicno(musicno 为空时跳过该规则) |
拒 | :148-149 |
黑名单明细:
AD_MARKERS(host + path上做小写contains,:26-33):"/ad_files/"、"my_ad_video"、"wb66"、"demo"。AD_HOSTS(精确或子域匹配,:43-46):"gz.ac16.vip"、"ac16.vip"。isAdVideo(:97-105):host取不到时退化为整串匹配(:101,宁可拦下也不放行广告)。
允许清单:
allowedMediaHosts(@Volatile var,:69,默认md.kasugano.cn+192.168.100.232; 设置项经setAllowedMediaHosts():73写入)。isAllowedMediaHost(host)(:84-88):精确匹配或子域匹配(value.endsWith(".$it"))。- 作用范围:只豁免第 5 条(裸 IP)。第 3、6、7 条不受影响。
.ts 文件名解析:tsBasename(path)(:193)用 TS_BASENAME = Regex("/([^/]+)\\.ts(?:/|$)", IGNORE_CASE)(:197)。
path 由 pathOf()(:178)取得,已剥离 query 与 fragment;host 由 hostOf()(:160)取得,
java.net.URI 失败时手工正则兜底(:163-168)。hostOf/pathOf 的剥离是硬性要求:
真源地址形如 …/480p/7200088.ts?token=…&t=…(:120-123 注释),拿整串判定会漏放或误杀。
6.2 「四处闸门同口径」的具体位置
| # | 路径 | 闸门代码 | 位置 |
|---|---|---|---|
| 1 | 原生 | SongUrlResolver.isValidSongUrl() → AdVideoFilter.isTrustedSongUrl(url, musicno) |
SongUrlResolver.kt:433-434(调用点 :266) |
| 2 | KtvJsBridge(JS 回来之后) | complete() → AdVideoFilter.resolve(url)(= isAdVideo,广告返回 null)→ 再 isTrustedDownloadUrl(url) |
KtvJsBridge.kt:391-394;resolve AdVideoFilter.kt:210;isTrustedDownloadUrl KtvJsBridge.kt:368-389 |
| 3 | SongApiClient(DB 回落) | fetchFromDb() → !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno) |
SongApiClient.kt:153;isLikelyDemoUrl :171-179 |
| 4 | JS | isDemoUrl(url, musicno) |
ktv_api.js:174-197(调用点 :472、:492、:500) |
| 5 | (最终兜底,非"四路径"之一) | SongOkDownloadManager.buildDownloadUrl:缓存地址 :257、新取地址 :276 |
SongOkDownloadManager.kt:248-298 |
第 2 处多一层 CDN 域名后缀白名单,这是其它三处没有的:
trustedHostSuffixes = ["ktvsky.com","origjoy.com","cherryonline.cn","ac16.vip","ktvdaren.com","j-make.cn"]
(KtvJsBridge.kt:359-362);host 既不在后缀白名单、又不在允许清单 → 判不可信(:386-388)。
自建源媒体域名(md.kasugano.cn / 192.168.100.232)正是靠允许清单通过这一层(:381)。
服务端同口径实现(对端参考):selfhost-source/server/ktv/resolver.py:121 is_trusted_url(url, tid)。
6.3 优先级总结
非 http(s) → 拒(最高)
广告黑名单(wb66 / demo / ac16.vip / ad_files / my_ad_video @ host+path)→ 拒(命中允许清单也拒)
R2 测试桶(pub-*.r2.dev + path 含 /ss/)→ 拒(命中允许清单也拒)
裸 IP → 拒,除非 host 命中 allowedMediaHosts(精确或子域)
.ts 文件名 != musicno → 拒(musicno 为空则跳过)
其余 → 放行
判定失败的处理:SongOkDownloadManager.buildDownloadUrl 的不可信分支(:282-289)会
purgeCachedSongFile(song, "untrusted-resolved-url")(:301-315,删除已落盘的歌曲文件 +
SongFileValidator.forget)并上报 MiliLog.error("点歌编号=… 状态=失败 详情=地址不可信(广告或文件名不符) url=…")。
缓存地址不可信时也删文件(:261-264,理由见 :258-260:广告能通过内容校验,一旦落盘会被 isDownloaded() 永久复用)。
7. 失败 / 回落矩阵
约定:官方源 = http://zk.ktv1.top:6677;自建源 = https://mdmuse.kasugano.cn(默认排第一)。
"某源失败" = demote(host) 后换下一个源;"回落" = 上一级路径返回 null 后交给下一级。
| 情形 | 官方源表现 | 自建源表现 | App 动作 |
|---|---|---|---|
code:200 + 地址可信(.ts 名 == 编号、域名/CDN 合法) |
msg=SUCCESS、真源 CDN(txog.ktvsky.com / download.origjoy.com) |
msg=SUCCESS、data 指向 md.kasugano.cn:4433/media/<编号>.ts(或内网裸 IP) |
promoteSource/promote 该源 → lastHost/报告源 记录 → 原生解除冷却(blockedUntil=0 SongUrlResolver.kt:269) → Eac19WindowGuard.noteUrlOk()(SongApiClient.kt:131) → 推送一次 → 最终闸门通过 → 落盘下载 |
业务码 403(HTTP 200 + {"code":403,"msg":"请求被拒绝:检测到异常行为"}) |
历史形态 | 服务端默认不会(用 404 表达未就绪) | JS:needRetry → sleep(3000) → 同 token 重试一次(ktv_api.js:482-486)→ 仍 403 → demote。原生:code==403 → sawForbidden=true(SongUrlResolver.kt:373)→ 判失败 → demote。全部源失败且 sawForbidden → 3 分钟冷却(BLOCKED_COOLDOWN_MS :47)+ MiliLog.localError("SOURCE-BLOCKED 源站返回 HTTP 403…") + Eac19WindowGuard.noteRiskBlocked()(只写日志、无弹窗)+ noteAllSourcesFailed() |
| HTTP 403(状态码本身) | 实测主源 /i.php 会直接 403 |
不会 | KtvJsBridge.request:MiliLog.localError("HTTP-403 …"),且 URL 含 /i.php 或 /music/do.php → Eac19WindowGuard.noteRiskBlocked()(KtvJsBridge.kt:568-577)→ raw 返回 JS → 该源判失败 → demote。原生 callEnvelope:status==403 → sawForbidden(SongUrlResolver.kt:398) |
业务码 404({"code":404,"msg":"not in library"} / not ready: pending) |
— | 服务端约定用它表达"这首还没下载完成"(service.py:28、:90-96) |
一律当"该源没地址":code!=200 → 同上重试一次 → demote → 换源;不会触发 sawForbidden,也不弹"换 IP" |
广告/错号地址(code:200 但 isDemoUrl 命中:wb66 / 广告域名 / .ts 名 ≠ 编号) |
出口 IP 授权窗口失效时的新形态:对每首歌都返回同一段广告 | 不应发生 | JS:needRetry=true(ktv_api.js:472-475)→ 3 秒退避重试一次 → 仍广告 → demote。全部源都只给广告(原生统计 adOnlyCount>0 && otherFailCount==0,SongUrlResolver.kt:296-304)→ 3 分钟冷却 + MiliLog.error("SOURCE-BLOCKED 全部 N 个源只返回广告/测试资源(该出口 IP 授权窗口失效)") + noteRiskBlocked() + noteAllSourcesFailed() |
| 地址不可信但拿到了 URL(最终闸门拒收) | 同广告行 | — | SongOkDownloadManager:MiliLog.error("… 状态=失败 详情=地址不可信(广告或文件名不符) url=…") + purgeCachedSongFile → 下载返回空 → onDownloadFailed("无法生成下载地址") |
| 超时 | — | — | 单次请求:JS 默认 8000ms(ktv_api.js:231);原生 TIMEOUT_MS = 8000(SongUrlResolver.kt:40);裸 Socket 连接/读取各 coerceIn(1000, 30000)(KtvJsBridge.kt:635、:660)。JS 整体受桥注入的 180 秒兜底(JS_REQUEST_TIMEOUT_MS KtvJsBridge.kt:48,setTimeout → finish('') :312);SongApiClient 等 185 秒(:122)。超时 → 该源失败 → demote → 换源;全部失败 → 回落原生 → 回落 DB → 「无法生成下载地址」 |
| HTTP 5xx | — | — | MiliLog.localError("HTTP-5xx …")(KtvJsBridge.kt:568-570,不触发风控提示)→ JS 判失败 → 重试一次 → demote;原生 callEnvelope 记 localWarn → null |
令牌 401(带 X-App-Token 被拒) |
官方源没有 /token(404/405)→ 从不带该头 |
/token 正常;令牌过期 → {"ok":false,"error":"令牌无效或已过期"} + 401 |
原生:AppTokenClient.withToken 判定 → invalidate → 重取 → 再发一次,只重试一次(AppToken.kt:195-201)。JS:tokenRejected → invalidateAppToken(host) → 重取头 → 不 sleep 立即重试一次(ktv_api.js:469-486)。第二次仍 401 → 该源判失败 → demote |
/token 404/405 |
命中(官方源) | 不会 | 记住"该源不支持令牌"(进程内永久)→ 之后不再尝试、也不带该头(AppToken.kt:141-146);请求照常发(服务端 app_token_enforce=false) |
/token 其它失败(网络/5xx/解析不出) |
— | 临时抖动 | 60 秒冷却(AppToken.FAILURE_BACKOFF_MS)→ 期间不带令牌发请求;冷却后自动重试 |
| 网络不可达(裸 Socket 与 HttpURLConnection 都失败) | — | — | MiliLog.localError("NET-ERR …") → 该源失败 → … → 全部失败 → 「无法生成下载地址」;下载层把它记进 networkFailed(SongOkDownloadManager.kt:387-390、isNetworkFailure :234-246),网络恢复后 retryNetworkFailed()(:220,最多 8 首) |
| 全部源彻底失败 | — | — | SongUrlResolver.resolve 末尾无条件调 Eac19WindowGuard.noteAllSourcesFailed()(:318)→ onAllFailed 弹一句**「远程源已全部失效,等待新版本修复」**,15 分钟节流(Eac19WindowGuard.kt:42、:73-84)。注意:刚进入 blockedUntil 冷却时的下一次取址会在 :227-232 直接返回 null,不会再弹(弹窗在进入冷却那一次已发) |
| 下载中断/卡死/文件异常 | — | — | 重试链:MAX_RETRY_COUNT = 3(:23)、STALL_TIMEOUT_MS = 25_000(:24,看门狗 :489-507)、退避 attempt*750ms 上限 2250ms(:525)。每次重试先 song.downloadUrl = null + SongApiClient.clearTokenCache()(:522-523,会重置 JS 会话、回到默认源顺序)再重新取址。落盘校验:MIN_VALID_FILE_SIZE(:414)、TsDecryptor.isEncrypted(:420)、SongFileValidator.inspect(:432,失败提示"服务器返回试听片段(N 秒),未保存也未加入已点" :442-445) |
| 用户可见提示 | — | — | 只有一句(Eac19WindowGuard.onAllFailed);风控 403 只写日志(原「歌曲源临时受限…换 IP」弹窗已按用户要求删除,Eac19WindowGuard.kt:36-37) |
8. 点歌推送(App → 自建服务器 /push_download)
8.1 时机与目标
| 项 | 值 / 位置 |
|---|---|
| 时机 | 拿到下载地址之后,在汇合点里:SongApiClient.getSongDownloadUrl 4 参版 :72-79,条件 !url.isNullOrEmpty() && musicno.isNotBlank();musicno 会 trim()(:75) |
| 调用 | PushDownloadClient.offer(musicno, url, songName) PushDownloadClient.kt:33(fire-and-forget,异常只 Log.w :76) |
| 目标(默认规则) | PushDownload.effectiveServerUrl(configured, sourceHosts) PushDownload.kt:58-65:① 显式非空且不是 DISABLED_VALUES → 原样用;② 显式 off/none/no/0/false/关闭/禁用(:42)→ 关闭;③ 留空 → 若 sourceHosts 第一项是自建源(SourceHosts.isSelfHost :118,即主机名等于 mdmuse.kasugano.cn 或 192.168.100.232)就用它,否则关闭 |
| 端点 | endpoint(serverUrl) PushDownload.kt:73-78 = <去掉尾部 />/push_download;空串/非 http(s) → 返回 null → 连线程都不起(PushDownloadClient.kt:52) |
| 请求方式 | POST,Content-Type: application/x-www-form-urlencoded; charset=utf-8,User-Agent: MaidongKTV/1.2,连接+读取超时各 8000ms(PushDownloadClient.kt:75-104,PushDownload.TIMEOUT_MS :31) |
| 线程 | 独立守护线程 "push-download"(PushDownloadClient.kt:37-43),每次现读设置(KtvStore().load() :48-49,保证设置页改完立刻生效) |
| 鉴权 | body 里的 token(store.pushToken,默认 "local",KtvStore.kt:78/:309)+ 自建目标额外带 X-App-Token(:86,经 AppTokenClient.withToken(host) :61,host = SourceHosts.hostOf(serverUrl) :53) |
8.2 body 字段(PushDownload.buildForm :87-93)
| 字段 | 值 |
|---|---|
tid |
点歌编号(musicno.trim().toLongOrNull(),必须 > 0,否则直接放弃,PushDownloadClient.kt:34) |
url |
刚拿到的媒体地址,整体 URLEncoder.encode(:90;注释强调不编码会被服务端表单解析拆成多个字段,:83-85) |
name |
歌名(可选,仅用于服务器日志核对,不入库) |
token |
pushToken(空串时回落 "local",:88) |
8.3 去重与失败处理
- 5 分钟内存去重:
DEDUPE_MS = 5 * 60_000(:28);shouldSkip(tid, now):97-100; key 是tid.toString(),表上限MAX_REMEMBERED = 512(:34),超限时按now - DEDUPE_MS*4清旧(:107-113)。 - 去重检查在两处:
offer里先廉价短路(PushDownloadClient.kt:36,不起线程),submit里再判一次(PushDownload.kt:139)。 markPushed在sender.send之前执行(:140-141)→ 同一编号 5 分钟内失败也不重试。- 失败只写本地日志:
MiliLog.localWarn("PUSH-DL 推送失败 端点=… HTTP …"):95/MiliLog.localWarn("PUSH-DL 推送失败 端点=… <异常>"):99;成功也只写本地MiliLog.local("PUSH-DL 已推送 编号=… 服务器=…"):67。绝不弹提示、不阻塞、不改返回值。 submit的sender抛异常被吞(PushDownload.kt:141-145返回 false)。- 与令牌的关系:令牌拿不到就"不带
X-App-Token按老形态推"(服务端也接受 body 的push_token,pushdl.py:213-217);令牌被拒(401)时AppTokenClient.withToken会清缓存重取、只重试一次。 - 服务端行为(对端参考
pushdl.py:204-264):① 鉴权(X-App-Token或push_token); ② 同 IP 每分钟限流(超限 429 +Retry-After);③tid必须正整数;④url必须 http(s) 且过is_trusted_url(url, tid)闸门;⑤ 去重三态already_done|already_queued|recently_pushed; ⑥ 接受则push_enqueue(tid, url),name只写事件。
9. JS 与原生一致性
9.1 KTV_BRIDGE_API 版本号的作用
| 位置 | 值/用法 |
|---|---|
ktv_api.js:15 |
注释标记 // KTV_BRIDGE_API: 14 (Android 缓存校验标记,请勿删除) |
ktv_api.js:535 |
global.KtvBridgeApiVersion = 14(供 JS 侧/调试读取) |
KtvJsBridge.kt:403 |
isValidScript() 要求缓存文件同时包含 "KtvApi" 且包含 "KTV_BRIDGE_API: 14"(长度还必须在 1001..500000 字节) |
KtvJsBridge.kt:427-430 |
updateCache() 下载远端脚本后 require(script.contains("KTV_BRIDGE_API: 14")),否则抛错、删临时文件、保留旧脚本 |
作用:版本闸门。远端热更新脚本若不兼容(标记不等于 14)→ 拒绝落地,
设备继续用已有缓存/内置脚本。改 ktv_api.js 的取址协议时必须同步提升这里的数字(两处)。
9.2 KtvApiConfig 注入了哪些字段
只在 if (!window.api) 分支里注入一次(KtvJsBridge.kt:314-331,取址时执行):
| 字段 | 注入值 | 位置 |
|---|---|---|
VER |
'2.0' |
:318 |
VN |
'4.1.3.03281430' |
:319 |
HOSTS |
sourceHostsForJs()(设置项 sourceHosts,空则 SourceHosts.DEFAULT) |
:321(读取 :256-260) |
HOST |
hosts.firstOrNull() |
:322(JS 侧从未读取该字段,见 §11) |
ALLOWED_MEDIA_HOSTS |
allowedMediaHostsForJs()(设置项,空则默认清单) |
:323(读取 :263-267) |
构造:new window.KtvApi({mac: DEVICE_MAC, sn: DEVICE_SN, maxDeviceRetries: 0, debug: true})(:325);
并包一层 regenerateDevice,防止 JS 自己换设备身份(:326-331)。
未注入 APP_ID / APP_KEY / SDK_KEY —— 它们沿用 ktv_api.js 自带值(当前与 Kotlin 侧一致);
也未注入 MWS / RSA_PUBKEY。
debug: true → KtvApi._log() 走 console.log(ktv_api.js:294),
被 wrapHtml 注入的 console 桥转发到 android.log(KtvJsBridge.kt:478-484)→
NativeBridge.log → MiliLog.local("JS " + message)(:493-498,只写本地)。
9.3 脚本来源与热更新回退链
- 优先用缓存
filesDir/ktv_api_cache.js(CACHE_FILE:43),仅当isValidScript通过才用(:110-112)。 - 否则用打包内置
assets/mobile/ktv_api.js(readAssetScript:396)。 updateCache(:408-451)在后台线程拉远端https://gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js(REMOTE_JS_URL:45-46,UA: KTV-Updater/2.0,连接 10s / 读取 15s,≤2MB), 校验通过后原子替换;若当前空闲(ready && callbacks.isEmpty() && !destroyed)立即热重载(:438-443), 否则下次启动生效。- 缓存脚本加载后没有导出
window.KtvApi→ 回退内置 asset 一次(:173-177,assetRetryAttempted防重入)。 - 内置 asset 也没导出 →
markUnavailable("KtvApi 未导出")(:182)→ 之后取址直接走原生通道, 不再每次白等 15 秒(SongApiClient.kt:96-98)。 - 看门狗:
BRIDGE_INIT_TIMEOUT_MS = 20_000(:50)内onPageFinished未回调或未导出 →markUnavailable("初始化超时:onPageFinished 未回调或 KtvApi 未导出")(:116-121)。 - WebView 构造抛异常 →
markUnavailable("WebView 初始化失败: …")(:122-126)。 - 桥后来就绪(热重载成功等)会自动清除
unavailable并上报一条JS-BRIDGE 已恢复可用(<原因> 已解除)(:164-169)。
其它一致性约定:
- 两套实现读同一份设置(
KtvStore.sourceHosts/allowedMediaHosts),默认值逐项一致。 - 两套实现走同一条裸 Socket 通道(原生用
KtvJsBridge.rawCleanGet(rawCleanPost))。 - 两套实现的风控表现一致:都只发
Host+Accept(/i.php、/music/do.php)。 - 原生是兜底、JS 是主路径:源站配置变更时 JS 能热更新,而
SongUrlResolver里的APP_ID/APP_KEY/SDK_KEY/VER/VN是编译进 APK 的镜像,必须发版才能改(SongUrlResolver.kt:26-28)。 wrapHtml(:454-489)额外注入诊断脚本:上报 WebView UA 与async/await支持(:472-477)、window.onerror(:466-469)、console.log桥(:478-484)。
10. 可核对清单(How to verify,8~12 条)
| # | 验证项 | 怎么做 | 期望 |
|---|---|---|---|
| 1 | 默认源列表 | 看 App「设置 → 歌曲源」文本框 / <存储根>/state.json 的 sourceHosts |
http://zk.ktv1.top:6677 在前(官方源优先)、https://mdmuse.kasugano.cn 在后(兜底) |
| 2 | 源列表立刻换源(原生) | 设置里只留一个错误源(如 http://127.0.0.1:1)保存 → 点一首未下载的歌 |
日志出现 SOURCE-HOSTS 歌曲源已更新(1 个): …(SongUrlResolver.kt:147,本地日志)+ NATIVE-SONG 全部源失败 … |
| 3 | JS 路径是否真的按新源走 | 同上改设置后不重启,看本地日志是否仍出现旧源 Try source (1/N): … |
⚠️ 仍会用旧顺序(需 resetApiSession 或重启才生效,见 §3.2 注) |
| 4 | 令牌接口(自建源) | 服务器上 curl -s http://<自建源>/token/status(或 GET /api/client_ip) |
live_tokens/pending_log 有签发计数;App 本地日志有 APP-TOKEN … 行 |
| 5 | 官方源"不支持令牌"记忆 | 把 sourceHosts 设成官方源打头,点一首歌,再看本地日志 |
一条 APP-TOKEN 源不支持令牌接口(HTTP 404/405),之后不再尝试 source=…,且后续点歌不再请求 /token |
| 6 | 令牌 401 只重试一次 | 单测:app/src/test/java/com/local/ktv/AppTokenTest.java 的 unauthorizedClearsCacheRefetchesAndRetriesExactlyOnce / unauthorizedRetryHappensOnlyOnceEvenIfStillRejected |
通过(fetchCount 断言只多一次) |
| 7 | 地址闸门四处口径 | 跑单测 AdVideoFilterTest、MediaHostAllowlistTest、SourceHostsTest |
全绿(含带 query/fragment 真源放行、广告仍拒、黑名单压过允许清单) |
| 8 | 广告地址被拦后的行为 | 本地日志搜 SONG-AD 丢弃不可信缓存地址 / SONG-AD 清除可疑歌曲缓存 / 远程日志搜 状态=失败 详情=地址不可信 |
地址被清、已落盘文件被删(purgeCachedSongFile),下次点歌重新取址 |
| 9 | 全部源失效提示 | 断网或让出口 IP 进入受限态后点歌 | 远程日志一条 NATIVE-SONG 全部源失败 编号=… 详情=token-fail|no-url|demo-or-mismatch 用时=…ms(SongUrlResolver.kt:307)+ 界面弹「远程源已全部失效,等待新版本修复」(15 分钟节流) |
| 10 | 断网 / 恢复表现 | 断开网络 → 播放页右上角角标「网络未连接」;恢复后远程日志 | NET-DOWN 设备网络已断开 → NET-UP 设备网络已恢复, 重建歌曲源会话 → NET-REFRESH reason=net-up retried=N(MainActivity.kt:3799/:3813/:3835) |
| 11 | 点歌推送(自建目标) | 服务器 curl -s <自建源>/push_download/status;App 本地日志搜 PUSH-DL |
accepted/duplicate 计数增长;App 本地一条 PUSH-DL 已推送 编号=… 服务器=…。填 off 后应无任何 PUSH-DL 行 |
| 12 | 点歌推送去重 | 5 分钟内反复点同一首(或反复取址同一编号) | 只推一次(PushDownloadTest.dedupeSameTidWithinFiveMinutes;shouldSkip :97) |
| 13 | 热更新脚本不兼容时回退 | 把 Gitea 上 ktv_api.js 的 KTV_BRIDGE_API: 16 改成别的值后重启 App |
远端脚本被拒(logcat JS update failed: Invalid or incompatible JS file),仍按缓存/内置脚本运行 |
| 14 | WebView 不可用时的原生兜底 | 真机日志搜 JS-BRIDGE / NATIVE-SONG |
有 JS-BRIDGE 判定本机 WebView 不可用(…),后续点歌直接走原生取址通道,之后 NATIVE-SONG 取址成功 编号=… 源=… urlHost=…(远程日志) |
| 15 | 本地日志文件 | <存储根>/logs/ktv.log(启动行 APP-INIT … logFile=<路径>) |
逐源/逐次细节(JS …、Try source、APP-TOKEN)都在这里;远程只有"结论"一条 |
11. 与预期不一致 / 值得注意的点(阅读代码时发现)
ktv_api.js的CONFIG.VN/CONFIG.VER是"死值":文件里写的是'4.100.5.11121504'/'4'(:22-23),实际取址用的是桥覆写后的'4.1.3.03281430'/'2.0'(KtvJsBridge.kt:318-319)。 直接读 JS 文件会得出错误的协议版本结论。CONFIG.HOST被注入但从未被读取:KtvJsBridge.kt:322写了它,ktv_api.js全文只用到CONFIG.HOSTS(getToken的默认值用CONFIG.HOSTS[0],:358)。属于无害的冗余字段。- 设置页改"歌曲源"对 JS 主路径不立即生效:注入代码在
if (!window.api)里, 设置页保存只调applySourceSettings()(MainActivity.kt:9451),不重置会话。 已构造的window.api.sourceOrder(构造时快照)仍生效,直到重启/断网恢复/下载重试触发resetApiSession()。 原生兜底路径则立刻生效 —— 两条路径在同一时刻可能用不同的源顺序。 - 业务码 403(HTTP 200)在 JS 路径不触发风控提示:
KtvJsBridge.request只在status == 403时调Eac19WindowGuard.noteRiskBlocked()(:575-577);JS 侧看到{"code":403}(HTTP 200) 时只是"该源失败 + 退避重试一次"。只有原生SongUrlResolver会在业务码 403 上置sawForbidden(:332、:373)。所以"业务码 403 风控"这个信号在 JS 路径上是丢失的(远程日志里只能看到点歌失败 … 详情=do.php code=403 …)。 - 401 重试确实只有一次,两套实现口径一致但细节不同:原生
AppTokenSession.withToken第二次结果 直接返回(AppToken.kt:200);JS 侧tokenRejected分支跳过 3 秒退避立即重试(ktv_api.js:478-481), 而风控/广告场景会sleep(3000)再重试(:483-485)。 AppTokenSession的"永久不支持"只是进程内记忆:unsupported是普通HashSet(AppToken.kt:110),不落盘,App 重启后会重新探测一次/token。文档/注释里的"永久"= 本次进程生命周期。KtvJsBridge.isTrustedDownloadUrl多一层 CDN 域名后缀白名单,且白名单里含ac16.vip(KtvJsBridge.kt:360)—— 也就是广告域名。它之所以没造成事故,仅仅因为complete()先调AdVideoFilter.resolve()(isAdVideo→AD_HOSTS含ac16.vip)把广告拦掉了(:392-394)。 这是一个依赖调用顺序的安全网:如果哪天有人把resolve()挪到isTrustedDownloadUrl之后,ac16.vip的广告就会被这条白名单放行。SongOkDownloadManager用song.filename推编号;缺filename时退化成song.id(:253-254)。song.id在曲库条目里可能是数据库主键,这个值会一路传给SongApiClient(闸门的musicno)与点歌推送的tid:非数字时推送直接放弃, 数字但与.ts文件名不符时会把正常地址误杀成"文件名不符"。代码里只用它做"下载层最终闸门"的 参数(:276传的是musicNoFromFile,可能为 null → 跳过文件名规则),但getSongDownloadUrl(musicNo.orEmpty(), …)(:269)传的是带回退的musicNo→ DB 回落路径的闸门仍会用这个可能有歧义的值。streamDownloadAndPlay(MainActivity.kt:5232)是死代码:全仓 grep 只有定义与一处注释, 没有任何调用点;实际"点歌即下即播"走的是startDownloadAndPlay→downloadSong→SongOkDownloadManager。它内部还留着一条不过最终闸门的getSongDownloadUrl调用(:5239) 与ThunderSDK/4.1.3的 UA、.tmp临时文件写法;如果将来复活它,需要补闸门。MainActivity.download(song)(:4984)完全不取址、不过闸门:直接用song.downloadUrl/videoUrl下载。它是"旧版备用"路径,若被曲库条目(Song.remote()会填downloadUrl)触发, 就绕过了AdVideoFilter。这是四条主路径之外的第五条消费点。- DB 回落路径静默吞异常:
fetchFromDb的catch (_: Exception) {}(SongApiClient.kt:157) 不写任何日志 —— 数据库打不开/查询异常时,外观与"DB 里没有 cloud_url"完全一样。 ensureConfig探测的是"当前队首"(SongUrlResolver.kt:239传allHosts.firstOrNull()), 而队首会被promote/demote改变;加上 30 分钟 TTL,实际探测对象在一次会话里可能不是固定那个源。 按当前默认顺序(自建源在首)它返回trial_times=0→ 不限制源数量;若把带trial_times=2的 官方源放首位,则一次取址只会试 2 个源(:240-244)。AGENTS.md与代码有轻微漂移:文档里写KTV_BRIDGE_API为 13("已升到 13"), 代码与ktv_api.js实际是 14(ktv_api.js:15/:535、KtvJsBridge.kt:403/:429)。AD_MARKERS现在含demo(AdVideoFilter.kt:32),只因为匹配范围已收敛到host + path(不再搜 query)。若将来有人把匹配范围改回整串,?token=…&t=…里的随机串会误杀真源。SongUrlResolver.DEFAULT_HOSTS与SourceHosts.DEFAULT是两份重复常量(:62-65/:31-34), 必须手工保持一致(SourceHosts.DEFAULT才是parseList真正使用的默认值,DEFAULT_HOSTS只用于hostOrder初值与安装日志:132)。/push_download的markPushed在发送之前(PushDownload.kt:140-141)—— 一次失败(网络抖动/服务器 5xx)后,同一编号在 5 分钟内不会重推。
12. 仍未确认的部分
do.php返回里的limit字段(服务端service.py:89返回2000)在 App 侧没有任何消费方; 其业务语义(是否表示试用次数/时长/并发)未在 App 代码中确认。官方源是否也返回该字段、 以及取值含义,未在代码中确认(需要抓包或问服务方)。/i.php返回的retry/ordertype/end_date字段:App 未读取,真实语义未在 App 代码中确认;heart_time已在 V1.6.3 被当作 token 缓存时长使用(语义仍以"实测 5400 秒"为准)。AdVideoFilter.replacementUrl(AdVideoFilter.kt:54)的运行期赋值点:全仓未发现任何写入, 即恒为null(="屏蔽"而非"替换")。是否有外部入口(如远程配置)未在代码中确认。- 官方源(
zk.ktv1.top:6677)是否支持/token:代码按 404/405 处理,实测结论未在本次阅读中验证。 CONFIG.MWS(https://mws.cherryonline.cn)与mwsLogin()(ktv_api.js:325-353)在当前取址链路里 没有被调用(KtvApi.init()直接return true,:526-529);它是否在别处(远程页面/其它 JS)被使用, 未在本次阅读范围内确认。SongUrlResolver的resolution是否真的存在非720的调用方:当前代码里所有调用点都传"720"; 其它入口(如未来的 1080p 设置)未在代码中确认。
13. 点歌延迟优化(V1.6.3/46,2026-10-15)
13.1 用户现象与真实原因
现象:点歌后要等 3~5 秒才发出(真正能取到地址的那次)歌曲源请求。
真实原因(代码路径 + 127.0.0.1 stub 对照测量):关键路径上串了一堆与"点哪首歌"无关的
一次性开销,其中最大的一条是 JS 里终态失败也固定 sleep(3000) 再重试一次:
// ktv_api.js(改前)
var needRetry = data.code !== 200; // 403(风控)/404(没有这首)也算 needRetry
if (needRetry) {
if (tokenRejected) { ... } // 令牌被拒才立即重试
else { await sleep(3000); } // ← 无条件 3 秒;用户实测的"3~5 秒"就在这里
data = await self._fetchSongUrl(...); // 3 秒后才发第二次
}
官方源(zk.ktv1.top:6677)的试用额度只有 trial_times: 2 / 5 分钟(api.php),
额度用完后 do.php 返回 HTTP 200 + 业务码 403(或对每首歌返回同一个广告资源)——
这两条都会让 JS 白等 3 秒再重试一次,然后才 demoteSource 换到兜底源。
而 promoteSource 会把成功过的源提到队首,于是"官方源成功一首 → 下一首又排第一 → 又白等 3 秒"
会反复出现,与"点歌后 3~5 秒"完全吻合。
其余叠加项(每项都是一次完整往返,全部排在 do.php 之前):
| # | 位置(改前) | 问题 |
|---|---|---|
| 1 | SongUrlResolver.resolveSourced → ensureConfig() |
每首歌的第一个请求是 POST api.php(当前官方节点没有这个接口,白等一次往返) |
| 2 | SongUrlResolver.fetchToken() |
没有任何缓存:每首歌、每个源都重打一次 /i.php(弱网下还要算 DNS + TCP + TLS) |
| 3 | AppTokenClient.tokenFor() |
懒加载:第一次取址才 GET /token(阻塞在 do.php 之前);且网络 I/O 在全局锁里,一个慢源会阻塞其它源 |
| 4 | KtvJsBridge.getSongUrl |
取址前读两次 state.json(sourceHostsForJs() + allowedMediaHostsForJs() 各一次) |
| 5 | SongApiClient.resolveSongDownloadUrl |
桥就绪闸门最长等 15 秒(老盒子首次点歌会等满) |
13.2 改了什么
| 措施 | 实现位置 |
|---|---|
| 终态失败不退避/不重试;可重试失败退避 800ms(原 3000ms) | ktv_api.js isTerminalFailure() / RETRY_BACKOFF_MS;KTV_BRIDGE_API 15 → 16 |
上游 /i.php token 按 host 缓存(TTL = heart_time,默认 90 分钟,单飞,失败 30s 冷却) |
SourceTokenCache.kt;SongUrlResolver.fetchToken/loadUpstreamToken |
| App 令牌按 host 单飞(网络 I/O 不占全局锁) | AppToken.kt AppTokenSession.tokenFor |
后台预热:JS 会话 + 各源 /i.php + api.php + DNS/TCP/TLS + 各源 /token |
Preheat.kt(纯逻辑)+ SourcePreheat.kt(接线);触发点:MainActivity.onCreate / onResume / showSearchPage / loadBrowsePage / refreshNetworkState / JS 桥就绪 |
api.php 移出取址关键路径(后台预热,失败仍进 30 分钟负缓存) |
SongUrlResolver.preheatConfig() |
取址路径只读一次 state.json;配置注入与 window.api 构造收敛到 apiBootstrapJs() |
KtvJsBridge.sourceConfigForJs() / apiBootstrapJs() |
| 分阶段耗时日志(只写本地) | SongFetchTrace.kt(Kotlin 侧)+ ktv_api.js _timing()(JS 侧,桥注入 window.api.__orderAt) |
日志形态(<存储根>/logs/ktv.log):
FETCH-TIME 编号=7303235 阶段=点歌→取址任务开始(含 io 池排队) 自点歌=12ms
FETCH-TIME 编号=7303235 阶段=闸门(等 JS 桥就绪) 自点歌=14ms 详情=用时=1ms ready=true
FETCH-TIME 编号=7303235 阶段=点歌→i.php请求发出 自点歌=21ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=i.php返回 自点歌=182ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=点歌→do.php请求发出 自点歌=190ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=do.php返回 自点歌=343ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=点歌→取到地址 自点歌=350ms 源=https://mdmuse.kasugano.cn
(上例数字来自 stub 测量,真机数值看现场日志。)
13.3 对照测量(127.0.0.1 stub,真实脚本 + 真实 Kotlin 逻辑)
-
JS 路径:
node maidong-ktv/tools/measure_ktv_js.js(随仓库提交;用assets/mobile/ktv_api.js的真实脚本, 把android.httpGet/android.appToken换成"记录时间戳 + 同步 sleep(150ms 往返) + 按 host 缓存"的 stub)。 每个场景连点 4 首,第 1 首的"点歌 → 拿到地址":场景(官方源首答失败形态) 优化前 优化后 优化后 + 预热 业务码 403(风控/额度耗尽) 4064ms 907ms 307ms 业务码 404(该源没有这首) 4059ms 905ms 303ms 只发广告(code=200 + 广告地址) 4060ms 1858ms 1255ms 第 3 首(官方源前两首成功、第 3 首起 403) 3757ms 602ms 302ms 其中"3 秒白等"在时间线上看得很清楚(
quota2第 3 首):优化前:0ms do.php(官方) → 3155ms do.php(重试,中间就是 sleep 3000) …;优化后:0ms do.php(官方) → 151ms i.php(兜底源) → 452ms do.php(兜底源)。 另外 4 首的do.php请求数在终态失败场景由 6 → 5(少了一次毫无意义的重试)。 -
预热成本:一轮预热 = 2 个源 × (
/i.php+/token) ≈ 4 次往返(stub 150ms → 603ms), 全在后台线程;之后每个触发原因 20 秒节流、缓存命中后不再发请求。
⚠️ 测量的边界:stub 只能证明"JS 逻辑 + 网络往返"的差异;主线程处理、io 池排队、
WebView evaluateJavascript 延迟量不到 —— 那部分由设备端 FETCH-TIME 阶段=点歌→取址任务开始
(含 io 池排队)与 阶段=闸门 两条日志给出,不要用 stub 数字外推到真机。