精简远程日志: 每次事件一条汇总, 细节只留本地

- MiliLog 新增只写本地通道 local/localWarn/localError
  (队列改为 Entry(local, remote), remote 为 null 即只写本地、跳过 HTTP)
- 本地日志文件改为纯文本 yyyy-MM-dd HH:mm:ss.SSS LEVEL text, 单行上限 4000
  (远程仍按 900 截断) —— 本地是"详细记录", 要人能直接读
- 逐源/逐次日志全部改本地:
  · SongUrlResolver: i.php/do.php 异常、token 失败、do.php code、HTTP 4xx/5xx、
    响应非 JSON、冷却跳过、403 提示、广告示例 URL
  · KtvJsBridge: JS 控制台转发(量最大)、单次 HTTP-<status>、NET-ERR
- 结果: 每次取址远程恰好一条
  成功 NATIVE-SONG 取址成功… / 失败 NATIVE-SONG 全部源失败… / 全广告 SOURCE-BLOCKED…
  (改前 9 个源逐个上报, 一次取址 9+ 条)
- 保留: JS-BRIDGE…(状态变化)、点歌失败…(每首一行)、SONG-AD…(广告拦截) 仍上报远程
- AGENTS: 写日志约定新增第 5 条(远程=结论、本地=过程), JS 日志规则改为只进本地

版本号按规则 3: 1.5.7(39) → 1.5.8(40); 按要求未打包
验证: compileReleaseKotlin + 单元测试(9 例全过)
This commit is contained in:
zijunchiang
2026-10-08 03:42:03 +08:00
parent d99ed25589
commit 745a180258
5 changed files with 87 additions and 33 deletions
+21 -9
View File
@@ -195,8 +195,8 @@ $BT/apksigner verify 目标名.apk
### 版本号 ### 版本号
- `app/build.gradle`:`versionName "1.5.7"` / `versionCode 39` - `app/build.gradle`:`versionName "1.5.8"` / `versionCode 40`
(`1.5.5`/`37` 已发布;`1.5.6` = 更新源加 Gitee、`1.5.7` = 删除「歌曲源临时受限」弹窗,均尚未发布。18~21 曾被本机临时诊断包占用) (`1.5.5`/`37` 已发布;`1.5.6` = 更新源加 Gitee、`1.5.7` = 删除「歌曲源临时受限」弹窗、`1.5.8` = 精简远程日志,均尚未发布。18~21 曾被本机临时诊断包占用)
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`):
任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。
@@ -287,11 +287,11 @@ $BT/apksigner verify 目标名.apk
`JS_REQUEST_TIMEOUT_MS`(180s) 预算,表现为**「无法生成下载地址」且日志里什么都没有**。 `JS_REQUEST_TIMEOUT_MS`(180s) 预算,表现为**「无法生成下载地址」且日志里什么都没有**。
正确写法:`Socket().connect(InetSocketAddress(host, port), connectTimeout)`; 正确写法:`Socket().connect(InetSocketAddress(host, port), connectTimeout)`;
3. **失败必须上报**:裸通道失败打 `RAW-ERR`、HttpURLConnection 回退失败打 `NET-ERR` 到远程日志。 3. **失败必须上报**:裸通道失败打 `RAW-ERR`、HttpURLConnection 回退失败打 `NET-ERR` 到远程日志。
- **JS 侧日志必须能进远程日志**:`ktv_api.js` 的 `_log()` 走 `console.log`(桥以 `debug:true` 实例化), - **JS 侧日志默认只进本地(2026-10 精简)**:`ktv_api.js` 的 `_log()` 走 `console.log`
默认只进 WebView 控制台、现场无法取证。`KtvJsBridge.wrapHtml()` 已注入 console 桥把 (桥以 `debug:true` 实例化),`KtvJsBridge.wrapHtml()` 注入 console 桥把它转发到
`console.log` 转发到 `android.log` → `MiliLog.info("JS ...")`。 `android.log` → **`MiliLog.local("JS …")`(只写本地文件,不上报远程)**。
点歌失败时先搜远程日志里的 **`JS `** 与 **`点歌失败`** 两行:前者给 逐步过程(Try source / Token OK/FAIL / do.php code=)在本地 `logs/ktv.log` 里看;
「Try source / Token OK/FAIL / do.php code=」的逐步过程,后者给 JS 汇总的失败原因。 远程日志只看一行汇总 **`点歌失败 编号=… 请求源=… 详情=…`**。
- **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底) - **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底)
- ⚠️ **`gz.ac16.vip` 是"源站入口"而不是"广告主机"的典型例子(2026-10 实测)**: - ⚠️ **`gz.ac16.vip` 是"源站入口"而不是"广告主机"的典型例子(2026-10 实测)**:
该域名对任何 `/<编号>.ts` 都返回**同一段广告视频**,因此它留在 `AdVideoFilter.AD_HOSTS` 该域名对任何 `/<编号>.ts` 都返回**同一段广告视频**,因此它留在 `AdVideoFilter.AD_HOSTS`
@@ -414,14 +414,26 @@ $BT/apksigner verify 目标名.apk
4. **日志要能自我定位**:失败行写清"哪一步/哪个源/耗时/原因",例如 4. **日志要能自我定位**:失败行写清"哪一步/哪个源/耗时/原因",例如
`NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch 用时=...ms`; `NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch 用时=...ms`;
桥侧由 `JS-BRIDGE ...` 说明 WebView 到底卡在哪一步(初始化失败/KtvApi 未导出/20s 超时)。 桥侧由 `JS-BRIDGE ...` 说明 WebView 到底卡在哪一步(初始化失败/KtvApi 未导出/20s 超时)。
5. **状态样本 + 事件**缺一不可:事件(点歌/失败)说明"发生了什么",但排查"随时间变化"的 5. **⚠️ 远程日志只留"每次事件一条"的汇总,细节走本地(2026-10 精简)**:
`MiliLog.local(msg)` / `localWarn` / `localError` **只写本地文件、不上报远程**。
判定标准——**逐源 / 逐次重试 / 单次 HTTP 错误 / JS 控制台输出**一律用 `local*`;
远程只保留"这件事的结论":
- 取址:成功一条 `NATIVE-SONG 取址成功 编号=… 源=… urlHost=… 用时=…ms`,
失败一条 `NATIVE-SONG 全部源失败 编号=… 详情=… 用时=…ms`,
全部只发广告一条 `SOURCE-BLOCKED 全部 N 个源只返回广告/测试资源…`
→ **每次取址远程恰好一条**(改前是每个源一条,9 个源就是 9+ 条);
- `JS …`(ktv_api.js 的 console 转发)**只进本地**,不再进远程;
JS 路径失败仍由远程的 `点歌失败 编号=… 请求源=… 详情=…` 一行说明原因。
⚠️ 新加日志时先问自己:"这条是'结论'还是'过程'?" —— 过程写本地。
6. **状态样本 + 事件**缺一不可:事件(点歌/失败)说明"发生了什么",但排查"随时间变化"的
问题(空间变少、曲库丢失、长期离线)需要状态样本 —— 现由 6 小时一条 `HEALTH ...` 问题(空间变少、曲库丢失、长期离线)需要状态样本 —— 现由 6 小时一条 `HEALTH ...`
(版本/在线/可用空间/曲库大小/根目录)提供。 (版本/在线/可用空间/曲库大小/根目录)提供。
- ⚠️ **远程日志绝不能静默失效**(2026-10 现场反馈"手动更新曲库后远程日志失效了"): - ⚠️ **远程日志绝不能静默失效**(2026-10 现场反馈"手动更新曲库后远程日志失效了"):
1. `MiliLog.init()` **可以重复调用并会自动重启发送线程**(旧实现是 1. `MiliLog.init()` **可以重复调用并会自动重启发送线程**(旧实现是
`if (started.getAndSet(true)) return`,一旦线程退出就永久不再发送,且无从察觉); `if (started.getAndSet(true)) return`,一旦线程退出就永久不再发送,且无从察觉);
2. `sendLoop` 整体包了 `catch (Throwable)` 兜底 —— 任何异常都不允许让发送线程退出; 2. `sendLoop` 整体包了 `catch (Throwable)` 兜底 —— 任何异常都不允许让发送线程退出;
3. **本地留档**:每行同时写入 `<存储根>/logs/ktv.log`(超 1MB 滚动为 `.prev`)。 3. **本地留档(纯文本)**:每行同时写入 `<存储根>/logs/ktv.log`
(格式 `yyyy-MM-dd HH:mm:ss.SSS LEVEL text`,单行上限 4000 字,超 1MB 滚动为 `.prev`)。
远程日志不可用时,让现场直接把该文件发出来即可取证;启动时会打一行 远程日志不可用时,让现场直接把该文件发出来即可取证;启动时会打一行
`APP-INIT ... logFile=<路径>` 指明位置; `APP-INIT ... logFile=<路径>` 指明位置;
4. `MiliLog.init` 已提到 **`KtvApplication.onCreate`**(早于 `MainActivity`), 4. `MiliLog.init` 已提到 **`KtvApplication.onCreate`**(早于 `MainActivity`),
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv" applicationId "com.local.ktv"
minSdk 21 minSdk 21
targetSdk 25 targetSdk 25
versionCode 39 versionCode 40
versionName "1.5.7" versionName "1.5.8"
ndk { ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
@@ -429,7 +429,7 @@ class KtvJsBridge(context: Context) {
Log.d(TAG, message) Log.d(TAG, message)
// 把 JS 侧日志接进远程日志:歌曲源解析(token/do.php/多源排序)的失败原因 // 把 JS 侧日志接进远程日志:歌曲源解析(token/do.php/多源排序)的失败原因
// 以前只走 console.log,现场完全看不到。加上前缀便于检索与过滤。 // 以前只走 console.log,现场完全看不到。加上前缀便于检索与过滤。
runCatching { MiliLog.info("JS ${message.take(180)}") } runCatching { MiliLog.local("JS ${message.take(180)}") }
} }
@JavascriptInterface @JavascriptInterface
@@ -482,7 +482,7 @@ class KtvJsBridge(context: Context) {
if (status > 0) { if (status > 0) {
// 远程日志: 风控/服务端错误(403/5xx)实时上报, 便于排查间歇性点歌失败。 // 远程日志: 风控/服务端错误(403/5xx)实时上报, 便于排查间歇性点歌失败。
if (status == 403 || status >= 500) { if (status == 403 || status >= 500) {
MiliLog.error("HTTP-$status $method $url") MiliLog.localError("HTTP-$status $method $url")
} }
// 歌曲源试用窗口/风控到期: 对陌生出口 IP 可能返回 403"检测到异常行为", // 歌曲源试用窗口/风控到期: 对陌生出口 IP 可能返回 403"检测到异常行为",
// 换公网 IP 恢复。通知 UI 弹一次提示引导换 IP。 // 换公网 IP 恢复。通知 UI 弹一次提示引导换 IP。
@@ -500,7 +500,7 @@ class KtvJsBridge(context: Context) {
if (fallbackStatus == 0) { if (fallbackStatus == 0) {
// 两个通道都失败 → 网络不可达/解析失败, 实时上报。 // 两个通道都失败 → 网络不可达/解析失败, 实时上报。
val err = runCatching { JSONObject(fallback).optString("error") }.getOrDefault("") val err = runCatching { JSONObject(fallback).optString("error") }.getOrDefault("")
MiliLog.error("NET-ERR $method $url $err") MiliLog.localError("NET-ERR $method $url $err")
} }
return fallback return fallback
} }
+51 -11
View File
@@ -60,10 +60,19 @@ object MiliLog {
private const val LOCAL_LOG_NAME = "ktv.log" private const val LOCAL_LOG_NAME = "ktv.log"
private const val LOCAL_LOG_MAX_BYTES = 1024L * 1024L private const val LOCAL_LOG_MAX_BYTES = 1024L * 1024L
/** 本地日志单行上限(本地是"详细记录",比远程的 900 放宽)。 */
private const val LOCAL_TEXT_MAX = 4000
private val started = AtomicBoolean(false) private val started = AtomicBoolean(false)
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
private val publicIpFetching = AtomicBoolean(false) private val publicIpFetching = AtomicBoolean(false)
private val queue = LinkedBlockingQueue<String>(MAX_QUEUE) /**
* 队列条目:`remote` 为 null 表示**只写本地**(详细过程,不占远程日志)。
* 约定见 [local]:远程日志每次事件只留一条汇总,逐源/逐次细节只进本地文件。
*/
private data class Entry(val local: String, val remote: String?)
private val queue = LinkedBlockingQueue<Entry>(MAX_QUEUE)
private var senderThread: Thread? = null private var senderThread: Thread? = null
/** 本地日志文件(后台线程写入,绝不占用主线程 I/O)。 */ /** 本地日志文件(后台线程写入,绝不占用主线程 I/O)。 */
@@ -149,16 +158,45 @@ object MiliLog {
fun warn(msg: String) = enqueue("WARN", msg) fun warn(msg: String) = enqueue("WARN", msg)
fun error(msg: String) = enqueue("ERROR", msg) fun error(msg: String) = enqueue("ERROR", msg)
/**
* **只写本地**的详细日志(2026-10 新增)。
*
* 约定:**远程日志"每次事件只留一条汇总"**,逐源/逐次尝试的细节走这里,
* 只写进 `<存储根>/logs/ktv.log`。取址最多遍历 N 个源 × 多次重试,
* 全部上报会把远程日志刷满而且没有新增信息(现场排查时噪音极大)。
*/
fun local(msg: String) = enqueueLocal("INFO", msg)
fun localWarn(msg: String) = enqueueLocal("WARN", msg)
fun localError(msg: String) = enqueueLocal("ERROR", msg)
private fun enqueueLocal(level: String, msg: String) {
if (!started.get()) return
val line = buildLocalLine(level, msg) ?: return
offer(Entry(line, null))
}
private fun enqueue(level: String, msg: String) { private fun enqueue(level: String, msg: String) {
if (!started.get()) return if (!started.get()) return
// 公网 IP 过期时顺带后台刷新(内部有 5 分钟节流,不会频繁调用外部接口)。 // 公网 IP 过期时顺带后台刷新(内部有 5 分钟节流,不会频繁调用外部接口)。
ensureServerIp() ensureServerIp()
val line = buildLocalLine(level, msg) ?: return
val body = buildBody(level, msg) ?: return val body = buildBody(level, msg) ?: return
// 队列满时丢最旧,保证新日志能进队。 offer(Entry(line, body))
if (!queue.offer(body)) {
queue.poll()
queue.offer(body)
} }
private fun offer(entry: Entry) {
// 队列满时丢最旧,保证新日志能进队。
if (!queue.offer(entry)) {
queue.poll()
queue.offer(entry)
}
}
/** 本地文件用的**纯文本**行:`yyyy-MM-dd HH:mm:ss.SSS LEVEL text`(不截断,便于取证)。 */
private fun buildLocalLine(level: String, msg: String): String? {
val text = msg.replace(Regex("[\\r\\n]+"), " ").trim()
if (text.isEmpty()) return null
return timestamp() + " " + level + " " + text.take(LOCAL_TEXT_MAX)
} }
private fun buildBody(level: String, msg: String): String? { private fun buildBody(level: String, msg: String): String? {
@@ -278,13 +316,15 @@ object MiliLog {
var consecutiveFailures = 0 var consecutiveFailures = 0
while (running.get()) { while (running.get()) {
try { try {
val body: String = try { val entry: Entry = try {
queue.take() queue.take()
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
break break
} }
// 本地留一份(远程日志不可用时,现场仍可取证)。 // 本地留一份(远程日志不可用时,现场仍可取证;也是"细节留本地"的落点)。
appendLocal(body) appendLocal(entry.local)
val body = entry.remote
if (body == null) continue // 只写本地的条目:不占远程日志
var sent = post(body) var sent = post(body)
if (!sent) { if (!sent) {
sleepQuietly(RETRY_SLEEP_MS) sleepQuietly(RETRY_SLEEP_MS)
@@ -293,10 +333,10 @@ object MiliLog {
if (sent) { if (sent) {
consecutiveFailures = 0 consecutiveFailures = 0
// 顺手把已积压的日志发完。 // 顺手把已积压的日志发完。
var next: String? = queue.poll() var next: Entry? = queue.poll()
while (next != null) { while (next != null) {
appendLocal(next) appendLocal(next.local)
post(next) next.remote?.let { post(it) }
next = queue.poll() next = queue.poll()
} }
} else { } else {
@@ -116,14 +116,14 @@ object SongUrlResolver {
fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? { fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? {
if (musicno.isBlank()) return null if (musicno.isBlank()) return null
val channel = fetch ?: run { val channel = fetch ?: run {
MiliLog.warn("NATIVE-SONG 未安装取址通道,跳过原生兜底") MiliLog.localWarn("NATIVE-SONG 未安装取址通道,跳过原生兜底")
return null return null
} }
// 已判定"源站对该出口 IP 只发广告/测试资源"时进入冷却:直接失败, // 已判定"源站对该出口 IP 只发广告/测试资源"时进入冷却:直接失败,
// 既让用户立刻看到结论,也避免继续冲击源站把限流拖得更久。 // 既让用户立刻看到结论,也避免继续冲击源站把限流拖得更久。
val nowMs = System.currentTimeMillis() val nowMs = System.currentTimeMillis()
if (nowMs < blockedUntil) { if (nowMs < blockedUntil) {
MiliLog.warn( MiliLog.localWarn(
"SOURCE-BLOCKED 冷却中(剩 ${(blockedUntil - nowMs) / 1000}s)跳过取址 编号=$musicno", "SOURCE-BLOCKED 冷却中(剩 ${(blockedUntil - nowMs) / 1000}s)跳过取址 编号=$musicno",
) )
return null return null
@@ -139,7 +139,7 @@ object SongUrlResolver {
for ((index, host) in hosts.withIndex()) { for ((index, host) in hosts.withIndex()) {
val token = runCatching { fetchToken(channel, host, mac) }.getOrElse { val token = runCatching { fetchToken(channel, host, mac) }.getOrElse {
MiliLog.warn("NATIVE-SONG i.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}") MiliLog.localWarn("NATIVE-SONG i.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
null null
} }
if (token.isNullOrEmpty()) { if (token.isNullOrEmpty()) {
@@ -150,7 +150,7 @@ object SongUrlResolver {
} }
val url = runCatching { fetchSongUrl(channel, host, mac, musicno, token, resolution, h265) } val url = runCatching { fetchSongUrl(channel, host, mac, musicno, token, resolution, h265) }
.getOrElse { .getOrElse {
MiliLog.warn("NATIVE-SONG do.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}") MiliLog.localWarn("NATIVE-SONG do.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
null null
} }
if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) { if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) {
@@ -185,10 +185,12 @@ object SongUrlResolver {
// 这里把它识别出来并接入 Eac19WindowGuard 的提示(引导重启光猫/路由器换公网 IP)。 // 这里把它识别出来并接入 Eac19WindowGuard 的提示(引导重启光猫/路由器换公网 IP)。
if (adOnlyCount > 0 && otherFailCount == 0) { if (adOnlyCount > 0 && otherFailCount == 0) {
blockedUntil = System.currentTimeMillis() + BLOCKED_COOLDOWN_MS blockedUntil = System.currentTimeMillis() + BLOCKED_COOLDOWN_MS
// 远程只留这一条汇总;逐源细节与示例 URL 只写本地。
MiliLog.error( MiliLog.error(
"SOURCE-BLOCKED 全部 $adOnlyCount 个源只返回广告/测试资源(该出口 IP 授权窗口失效)" + "SOURCE-BLOCKED 全部 $adOnlyCount 个源只返回广告/测试资源(该出口 IP 授权窗口失效)" +
" 编号=$musicno 示例=$adSample 用时=${elapsed}ms", " 编号=$musicno 用时=${elapsed}ms",
) )
MiliLog.localError("SOURCE-BLOCKED 示例广告地址=$adSample")
Eac19WindowGuard.noteRiskBlocked() Eac19WindowGuard.noteRiskBlocked()
} else { } else {
MiliLog.error( MiliLog.error(
@@ -197,7 +199,7 @@ object SongUrlResolver {
// 全都 403 = 源站明确风控(另一种受限形态,与"只发广告"并列),同样引导换公网 IP。 // 全都 403 = 源站明确风控(另一种受限形态,与"只发广告"并列),同样引导换公网 IP。
if (sawForbidden) { if (sawForbidden) {
blockedUntil = System.currentTimeMillis() + BLOCKED_COOLDOWN_MS blockedUntil = System.currentTimeMillis() + BLOCKED_COOLDOWN_MS
MiliLog.error("SOURCE-BLOCKED 源站返回 HTTP 403(风控/授权窗口失效)编号=$musicno") MiliLog.localError("SOURCE-BLOCKED 源站返回 HTTP 403(风控/授权窗口失效)编号=$musicno")
Eac19WindowGuard.noteRiskBlocked() Eac19WindowGuard.noteRiskBlocked()
} }
} }
@@ -215,7 +217,7 @@ object SongUrlResolver {
val sign = md5(params + APP_KEY) val sign = md5(params + APP_KEY)
val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null
if (json.optInt("code") != 200) { if (json.optInt("code") != 200) {
MiliLog.warn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}") MiliLog.localWarn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}")
return null return null
} }
return json.optString("token").takeIf { it.isNotEmpty() } return json.optString("token").takeIf { it.isNotEmpty() }
@@ -238,7 +240,7 @@ object SongUrlResolver {
val json = getJson(channel, "$host/music/do.php?$params&sign=$sign") ?: return null val json = getJson(channel, "$host/music/do.php?$params&sign=$sign") ?: return null
val code = json.optInt("code") val code = json.optInt("code")
if (code != 200) { if (code != 200) {
MiliLog.warn("NATIVE-SONG do.php 源=$host code=$code msg=${json.optString("msg")}") MiliLog.localWarn("NATIVE-SONG do.php 源=$host code=$code msg=${json.optString("msg")}")
return null return null
} }
return json.optString("data").takeIf { it.isNotEmpty() } return json.optString("data").takeIf { it.isNotEmpty() }
@@ -253,7 +255,7 @@ object SongUrlResolver {
// 403 = 源站风控的直接信号(2026-10 实测主源 w.w345.my/i.php 就是 403)。 // 403 = 源站风控的直接信号(2026-10 实测主源 w.w345.my/i.php 就是 403)。
// 记下来,全部源都失败时据此走 Eac19WindowGuard 的"换公网 IP"提示。 // 记下来,全部源都失败时据此走 Eac19WindowGuard 的"换公网 IP"提示。
if (status == 403) sawForbidden = true if (status == 403) sawForbidden = true
MiliLog.warn( MiliLog.localWarn(
"NATIVE-SONG HTTP $status ${runCatching { java.net.URI(url).host }.getOrNull()} " + "NATIVE-SONG HTTP $status ${runCatching { java.net.URI(url).host }.getOrNull()} " +
"err=${envelope.optString("error").take(80)}", "err=${envelope.optString("error").take(80)}",
) )
@@ -262,7 +264,7 @@ object SongUrlResolver {
val body = envelope.optString("body") val body = envelope.optString("body")
if (body.isBlank()) return null if (body.isBlank()) return null
return runCatching { JSONObject(body) }.getOrElse { return runCatching { JSONObject(body) }.getOrElse {
MiliLog.warn("NATIVE-SONG 响应非 JSON: ${body.take(120)}") MiliLog.localWarn("NATIVE-SONG 响应非 JSON: ${body.take(120)}")
null null
} }
} }