diff --git a/AGENTS.md b/AGENTS.md index ffc315f..2088384 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,7 +165,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.5.2"` / `versionCode 34`(18~21 被本机临时诊断包占用过) +- `app/build.gradle`:`versionName "1.5.3"` / `versionCode 35`(18~21 被本机临时诊断包占用过) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 @@ -357,6 +357,15 @@ $BT/apksigner verify 目标名.apk 5. **状态样本 + 事件**缺一不可:事件(点歌/失败)说明"发生了什么",但排查"随时间变化"的 问题(空间变少、曲库丢失、长期离线)需要状态样本 —— 现由 6 小时一条 `HEALTH ...` (版本/在线/可用空间/曲库大小/根目录)提供。 + - ⚠️ **远程日志绝不能静默失效**(2026-10 现场反馈"手动更新曲库后远程日志失效了"): + 1. `MiliLog.init()` **可以重复调用并会自动重启发送线程**(旧实现是 + `if (started.getAndSet(true)) return`,一旦线程退出就永久不再发送,且无从察觉); + 2. `sendLoop` 整体包了 `catch (Throwable)` 兜底 —— 任何异常都不允许让发送线程退出; + 3. **本地留档**:每行同时写入 `<存储根>/logs/ktv.log`(超 1MB 滚动为 `.prev`)。 + 远程日志不可用时,让现场直接把该文件发出来即可取证;启动时会打一行 + `APP-INIT ... logFile=<路径>` 指明位置; + 4. `MiliLog.init` 已提到 **`KtvApplication.onCreate`**(早于 `MainActivity`), + 这样即使更早的初始化抛异常,也已经有日志可查。 - 一段典型现场日志应为:`BOOT` → `STORAGE` → (点歌)`点歌编号=... 请求源=... 状态=成功 urlHost=...` → `PLAY ... src=local` → 每 6h `HEALTH ...`;异常时才有 `RAW-ERR`/`NET-ERR`/`NATIVE-SONG ...失败`。 - **网络状态与断网恢复**:`KtvNetworkMonitor`(object,走 `ConnectivityManager` 回调、不轮询) @@ -387,6 +396,14 @@ $BT/apksigner verify 目标名.apk 并调用 `Eac19WindowGuard.noteRiskBlocked()`(弹「歌曲源临时受限」引导重启光猫/路由器换 IP), 同时进入 3 分钟冷却(`BLOCKED_COOLDOWN_MS`)避免继续冲击源站;拿到真源立即解除。 - 现场处置:**更换公网出口 IP**(重启光猫/路由器重拨)即恢复;已下载的歌曲不受影响照常播。 + - **403 也是受限信号**:2026-10 实测**主源 `w.w345.my/i.php` 直接返回 HTTP 403**、 + 镜像源返回 200 但只给固定广告文件 —— 两种形态会同时出现。因此 + `KtvJsBridge.request` 的 403 判定已从"仅 `/music/do.php`"扩展到 **`/i.php` 也认**, + `SongUrlResolver` 也记录本轮是否见过 403(全部失败且见过 403 → 同样提示换 IP)。 + - **旧的试用领取接口 `api.php` 已 404**(源站撤了),因此 App 无法自行"领取"试用窗口。 + 若换公网 IP 后仍只发广告,基本可判定为**服务/授权层面**问题(`i.php` 的 + `ordertype=0`、`mwsLogin` 的 `license_box_count=0`/`license_duration=0` 都指向"无有效授权"), + 需与歌曲源服务方确认续订,**不是客户端能修的**。 - ⚠️ 排查这类问题时**不要用生产身份/出口 IP 做密集探测**:源站按出口 IP 限流, 短时间内大量 `i.php`/`do.php`(多 host × 多参数组合)会加重受限, 把"能用"变成"只发广告"。少量、间隔式验证即可。 diff --git a/app/build.gradle b/app/build.gradle index 8ece663..b5c8717 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 34 - versionName "1.5.2" + versionCode 35 + versionName "1.5.3" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/java/com/local/ktv/AdVideoFilter.kt b/app/src/main/java/com/local/ktv/AdVideoFilter.kt index 5d1ef4f..72a3d13 100644 --- a/app/src/main/java/com/local/ktv/AdVideoFilter.kt +++ b/app/src/main/java/com/local/ktv/AdVideoFilter.kt @@ -22,8 +22,8 @@ object AdVideoFilter { // 源站 2026-10 起"授权窗口失效"时会对每首歌返回同一个测试资源 // (实测 https://wb66.cn/f/d/7b7c9/10188555.ts)。JS 的 isDemoUrl 早就把 wb66 当 // 测试资源,这里对齐,保证两条取址路径判定一致。 + // ⚠️ 只收"wb66"这种高区分度标记:`demo` 之类太宽松的词会误伤真源 URL 里的随机 token。 "wb66", - "demo", ) /** diff --git a/app/src/main/java/com/local/ktv/KtvApplication.kt b/app/src/main/java/com/local/ktv/KtvApplication.kt index 4fe86d0..bbdbc2d 100644 --- a/app/src/main/java/com/local/ktv/KtvApplication.kt +++ b/app/src/main/java/com/local/ktv/KtvApplication.kt @@ -26,6 +26,13 @@ class KtvApplication : Application() { override fun onCreate() { super.onCreate() instanceRef = this + // 远程日志尽早启动:以前只在 MainActivity.onCreate 里 init, + // 一旦更早的初始化抛异常就直接崩溃、**一条日志都没有**,现场无从排查。 + // init 现在可重复调用且会自动重启发送线程,这里是"最靠前"的启动点。 + runCatching { + MiliLog.init(this) + MiliLog.info("APP-INIT 进程启动 ver=${BuildConfig.VERSION_NAME} logFile=${MiliLog.localLogPath()}") + }.onFailure { Log.e(TAG, "MiliLog.init failed", it) } OkDownload.setSingletonInstance(OkDownload.Builder(this).build()) Log.i(TAG, "KTV application initialized without native services") } diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt index ee1f192..098b2e1 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -486,7 +486,9 @@ class KtvJsBridge(context: Context) { } // 歌曲源试用窗口/风控到期: 对陌生出口 IP 可能返回 403"检测到异常行为", // 换公网 IP 恢复。通知 UI 弹一次提示引导换 IP。 - if (status == 403 && url.contains("/music/do.php")) { + // ⚠️ 取 token 的 `/i.php` 同样会 403(2026-10 实测主源 w.w345.my/i.php 就是 403)—— + // 以前只认 `/music/do.php`,全都 403 时用户看不到任何提示。 + if (status == 403 && (url.contains("/i.php") || url.contains("/music/do.php"))) { Eac19WindowGuard.noteRiskBlocked() } return raw diff --git a/app/src/main/java/com/local/ktv/MiliLog.kt b/app/src/main/java/com/local/ktv/MiliLog.kt index 11ace15..f0de3be 100644 --- a/app/src/main/java/com/local/ktv/MiliLog.kt +++ b/app/src/main/java/com/local/ktv/MiliLog.kt @@ -55,12 +55,20 @@ object MiliLog { private const val RETRY_SLEEP_MS = 1500L private const val FAILURE_BACKOFF_MS = 10_000L + /** 本地日志文件:远程日志一旦不可用(网络/服务端问题),现场仍能取证。 */ + private const val LOCAL_LOG_DIR = "logs" + private const val LOCAL_LOG_NAME = "ktv.log" + private const val LOCAL_LOG_MAX_BYTES = 1024L * 1024L + private val started = AtomicBoolean(false) private val running = AtomicBoolean(false) private val publicIpFetching = AtomicBoolean(false) private val queue = LinkedBlockingQueue(MAX_QUEUE) private var senderThread: Thread? = null + /** 本地日志文件(后台线程写入,绝不占用主线程 I/O)。 */ + @Volatile private var localLogFile: java.io.File? = null + @Volatile private var serverName: String = "android" /** 主机 IP:公网出口 IP([PUBLIC_IP_URL] 查询,5 分钟缓存)。 */ @@ -74,13 +82,50 @@ object MiliLog { /** 用 app context 初始化并启动发送线程。可重复调用。 */ fun init(context: Context) { - if (started.getAndSet(true)) return - serverName = sanitize(Build.MODEL).ifEmpty { "android" } + // 首次初始化时准备本地日志文件。 + if (localLogFile == null) { + runCatching { + val dir = java.io.File(AppPaths.root, LOCAL_LOG_DIR) + if (dir.exists() || dir.mkdirs()) localLogFile = java.io.File(dir, LOCAL_LOG_NAME) + } + } + if (!started.getAndSet(true)) { + serverName = sanitize(Build.MODEL).ifEmpty { "android" } + } + // ⚠️ 只要**没有存活的发送线程**就(重新)启动它。 + // 旧实现是 `if (started.getAndSet(true)) return`:一旦发送线程因任何原因退出 + // (例如有人调用过 [shutdown],或线程意外终止),后续 init 全是空操作, + // 日志只入队、永远不再发送 —— 现场表现就是「远程日志失效」,而且无从察觉。 + val alive = senderThread?.isAlive == true + if (running.get() && alive) { + ensureServerIp() + return + } running.set(true) senderThread = Thread({ sendLoop() }, "mili-glog").also { it.isDaemon = true; it.start() } + Log.i(TAG, "glog sender (re)started") ensureServerIp() } + /** 本地日志文件(供现场取证);未初始化时返回 null。 */ + fun localLogPath(): String? = localLogFile?.absolutePath + + /** + * 追加一行到本地日志(**由发送线程调用**,不在调用方线程做磁盘 I/O)。 + * 超过 [LOCAL_LOG_MAX_BYTES] 就滚动一次,保证文件不会无限增长。 + */ + private fun appendLocal(line: String) { + val file = localLogFile ?: return + runCatching { + if (file.length() > LOCAL_LOG_MAX_BYTES) { + val prev = java.io.File(file.parentFile, "$LOCAL_LOG_NAME.prev") + prev.delete() + file.renameTo(prev) + } + file.appendText(line + "\n", Charsets.UTF_8) + } + } + /** * 更新上报用的客户端网络/身份字段。 * @@ -232,31 +277,40 @@ object MiliLog { private fun sendLoop() { var consecutiveFailures = 0 while (running.get()) { - val body: String = try { - queue.take() - } catch (e: InterruptedException) { - break - } - var sent = post(body) - if (!sent) { - sleepQuietly(RETRY_SLEEP_MS) - sent = post(body) - } - if (sent) { - consecutiveFailures = 0 - // 顺手把已积压的日志发完。 - var next: String? = queue.poll() - while (next != null) { - post(next) - next = queue.poll() + try { + val body: String = try { + queue.take() + } catch (e: InterruptedException) { + break } - } else { - consecutiveFailures++ - Log.w(TAG, "glog send dropped (failures=$consecutiveFailures): ${body.take(120)}") - if (consecutiveFailures >= 3) { + // 本地留一份(远程日志不可用时,现场仍可取证)。 + appendLocal(body) + var sent = post(body) + if (!sent) { + sleepQuietly(RETRY_SLEEP_MS) + sent = post(body) + } + if (sent) { consecutiveFailures = 0 - sleepQuietly(FAILURE_BACKOFF_MS) + // 顺手把已积压的日志发完。 + var next: String? = queue.poll() + while (next != null) { + appendLocal(next) + post(next) + next = queue.poll() + } + } else { + consecutiveFailures++ + Log.w(TAG, "glog send dropped (failures=$consecutiveFailures): ${body.take(120)}") + if (consecutiveFailures >= 3) { + consecutiveFailures = 0 + sleepQuietly(FAILURE_BACKOFF_MS) + } } + } catch (t: Throwable) { + // 兜底:发送线程**绝不能因为任何异常退出**,否则远程日志会静默失效。 + Log.w(TAG, "glog loop error: ${t.javaClass.simpleName}: ${t.message}") + sleepQuietly(1000L) } } } diff --git a/app/src/main/java/com/local/ktv/SongUrlResolver.kt b/app/src/main/java/com/local/ktv/SongUrlResolver.kt index f5c51c0..68ba0f6 100644 --- a/app/src/main/java/com/local/ktv/SongUrlResolver.kt +++ b/app/src/main/java/com/local/ktv/SongUrlResolver.kt @@ -63,6 +63,9 @@ object SongUrlResolver { @Volatile private var sn: String = KtvJsBridge.DEVICE_SN + /** 本轮取址是否遇到过 HTTP 403(源站风控的直接信号)。 */ + @Volatile private var sawForbidden = false + /** 被判定为"源站只发广告/测试资源"后的冷却截止时间(0 = 未受限)。 */ @Volatile private var blockedUntil: Long = 0L @@ -121,6 +124,7 @@ object SongUrlResolver { ) return null } + sawForbidden = false val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn) val startedAt = System.currentTimeMillis() val hosts = synchronized(hostOrder) { hostOrder.toList() } @@ -183,7 +187,15 @@ object SongUrlResolver { ) Eac19WindowGuard.noteRiskBlocked() } else { - MiliLog.error("NATIVE-SONG 全部源失败 编号=$musicno 详情=$lastDetail 用时=${elapsed}ms") + MiliLog.error( + "NATIVE-SONG 全部源失败 编号=$musicno 详情=$lastDetail 用时=${elapsed}ms saw403=$sawForbidden", + ) + // 全都 403 = 源站明确风控(另一种受限形态,与"只发广告"并列),同样引导换公网 IP。 + if (sawForbidden) { + blockedUntil = System.currentTimeMillis() + BLOCKED_COOLDOWN_MS + MiliLog.error("SOURCE-BLOCKED 源站返回 HTTP 403(风控/授权窗口失效)编号=$musicno") + Eac19WindowGuard.noteRiskBlocked() + } } return null } @@ -231,6 +243,9 @@ object SongUrlResolver { val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return null val status = envelope.optInt("status") if (status !in 200..299) { + // 403 = 源站风控的直接信号(2026-10 实测主源 w.w345.my/i.php 就是 403)。 + // 记下来,全部源都失败时据此走 Eac19WindowGuard 的"换公网 IP"提示。 + if (status == 403) sawForbidden = true MiliLog.warn( "NATIVE-SONG HTTP $status ${runCatching { java.net.URI(url).host }.getOrNull()} " + "err=${envelope.optString("error").take(80)}",