Files
maidong-ktv/app/src/main/java/com/local/ktv/MiliLog.kt
T
zijunchiang 96bad3bd68 V1.5.3: 远程日志不再静默失效(+本地日志文件);补 403 受限识别
现场两个问题分别定位:

一、"歌曲源全部失效" —— 已确认是**源站对该出口 IP 的授权/风控**,非客户端可修
- 实测(本机与设备同一出口 188.253.112.231):
  · 主源 http://w.w345.my/i.php → **HTTP 403 Forbidden**(nginx 拦截页);
  · 镜像源(desktok.top / mm.kk456.top / w.w345.my:66 / z.w345.my / b.sktv.top / w345.my)
    返回 200,但对**每一首歌(含不存在的编号 1111111/9999999)都返回同一个广告/测试资源**
    https://wb66.cn/f/d/7b7c9/10188555.ts;
  · 换全新随机设备身份(mac/sn)结果完全相同 → 限制在**出口 IP** 层面,与设备无关;
  · 7 host × ls=1~3 × (720/1080 × h265 0/1) 全组合探测:0 个真源;
  · 旧的试用领取接口 api.php 已 **404**,App 无法自行领取窗口。
- 结论:源站侧无有效授权(i.php 的 ordertype=0、mwsLogin 的 license_box_count=0 /
  license_duration=0 均指向此)。**需更换公网 IP 或与歌曲源服务方确认续订**。
  我们拒绝这些地址是正确行为(否则就是 1.4.10 修过的"广告被当成歌曲播放")。

二、"手动更新数据库后远程日志失效" —— 数据库没问题,日志客户端有隐患,两者都已处理
- 实测新曲库 day=261003(md5 300AFD957BF332F02C17ED94A9F4E4CF,431,530,367 字节,
  解压 1.01GB、69.5 万首): 用 App 的**真实 SQL**(SONG_SELECT/推荐/中文搜索/拼音搜索/
  歌手检索/歌单/语种/歌手列表)逐条跑通 → **结构完全兼容,更新曲库不会破坏 App**。
- 日志侧修复(原来确实可能"静默失效"):
  · MiliLog.init 改为**可重复调用并自动重启发送线程**(旧实现 `if (started.getAndSet(true)) return`,
    线程一旦退出就永久不再发送且无任何迹象);
  · sendLoop 整体加 catch(Throwable) 兜底,任何异常都不允许发送线程退出;
  · 新增**本地日志文件** <存储根>/logs/ktv.log(>1MB 滚动为 .prev),
    远程日志不可用时现场可直接取文件;启动打 APP-INIT 行给出路径;
  · MiliLog.init 提前到 KtvApplication.onCreate,早于 MainActivity,早期崩溃也有日志。
- 补 403 识别: KtvJsBridge 的 403 判定从仅 /music/do.php 扩展到 /i.php 也认
  (主源 i.php 就是 403);SongUrlResolver 记录本轮是否见过 403,全部失败且见过 403 时
  同样走 Eac19WindowGuard 提示换 IP。

AdVideoFilter: 仅保留高区分度标记 wb66,去掉过于宽松的 demo(避免误伤真源 URL 里的随机 token)。

版本 1.5.2(34) → 1.5.3(35);签名证书不变。测试 9 例全通过。
2026-10-03 21:03:07 +08:00

357 lines
14 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.local.ktv
import android.content.Context
import android.os.Build
import android.util.Log
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
/**
* 远程日志客户端 —— GlogCenter(gotoeasy/glogcenter)HTTP 接口。
*
* 接口:`POST http://log.kasugano.cn/glc/v1/log/add`,`Content-Type: application/json`,
* 单条 JSON 对象(批量用 `/glc/v1/log/addBatch`,此处按单条上报)。
*
* 字段(均为字符串,对应 GlogCenter 页面列):
* system 系统名(本 App 固定 [SYSTEM])
* date 日期时间,格式 `yyyy-MM-dd HH:mm:ss.SSS`(务必与检索格式一致)
* text 日志内容
* servername 主机名(设备型号)
* serverip 主机 IP —— 公网出口 IP,由 [PUBLIC_IP_URL] 查询,结果缓存 [PUBLIC_IP_TTL_MS]
* clientip 客户端 IP —— 设备当前网卡获取到的局域网 IP(由调用方注入)
* loglevel 日志级别(INFO / WARN / ERROR)
* traceid 追踪码(此处用设备标识)
* user 用户(此处用设备标识)
*
* 策略:单后台线程 + 有界队列;发送失败重试一次,连续失败退避;尽力而为,不阻塞业务。
* 日志事件:启动设备/网络信息、点歌命中的源与结果、接口 403/网络错误、下载/播放失败。
*/
object MiliLog {
private const val TAG = "MiliLog"
private const val ENDPOINT = "http://log.kasugano.cn/glc/v1/log/add"
/** 公网 IP 查询接口(主机 IP)。 */
private const val PUBLIC_IP_URL = "https://ipinfo.io/ip"
/** 公网 IP 缓存时长:5 分钟内复用,避免频繁调用外部接口。 */
private const val PUBLIC_IP_TTL_MS = 5 * 60 * 1000L
/** 公网 IP 查询失败后的重试间隔。 */
private const val PUBLIC_IP_RETRY_MS = 60 * 1000L
private const val PUBLIC_IP_TIMEOUT_MS = 6000
/** GlogCenter 页面上的「系统名」,用于区分应用。 */
private const val SYSTEM = "maidongktv"
private const val MAX_TEXT_LEN = 900
private const val MAX_QUEUE = 300
private const val CONNECT_TIMEOUT_MS = 8000
private const val READ_TIMEOUT_MS = 8000
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<String>(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 分钟缓存)。 */
@Volatile private var serverIp: String = ""
@Volatile private var serverIpAt: Long = 0L
/** 客户端 IP:设备当前网卡获取到的局域网 IP。 */
@Volatile private var clientIp: String = ""
@Volatile private var traceId: String = ""
@Volatile private var user: String = ""
/** 用 app context 初始化并启动发送线程。可重复调用。 */
fun init(context: Context) {
// 首次初始化时准备本地日志文件。
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)
}
}
/**
* 更新上报用的客户端网络/身份字段。
*
* @param ip 设备当前网卡获取到的局域网 IP(clientip),变化时会顺带复核公网 IP
* @param deviceId 设备标识(同时写入 user / traceid)
*/
fun updateDeviceContext(ip: String? = null, deviceId: String? = null) {
if (!ip.isNullOrBlank() && ip != clientIp) {
clientIp = ip
// 局域网 IP 变化通常意味着换了网络,公网出口 IP 需要立即复核。
ensureServerIp(force = true)
}
if (!deviceId.isNullOrBlank()) {
user = deviceId
traceId = deviceId
}
}
fun info(msg: String) = enqueue("INFO", msg)
fun notice(msg: String) = enqueue("INFO", msg)
fun warn(msg: String) = enqueue("WARN", msg)
fun error(msg: String) = enqueue("ERROR", msg)
private fun enqueue(level: String, msg: String) {
if (!started.get()) return
// 公网 IP 过期时顺带后台刷新(内部有 5 分钟节流,不会频繁调用外部接口)。
ensureServerIp()
val body = buildBody(level, msg) ?: return
// 队列满时丢最旧,保证新日志能进队。
if (!queue.offer(body)) {
queue.poll()
queue.offer(body)
}
}
private fun buildBody(level: String, msg: String): String? {
val text = msg.replace(Regex("[\\r\\n]+"), " ").trim()
if (text.isEmpty()) return null
val truncated = if (text.toByteArray(Charsets.UTF_8).size > MAX_TEXT_LEN) {
text.take(MAX_TEXT_LEN)
} else {
text
}
return runCatching {
JSONObject().apply {
put("system", SYSTEM)
put("date", timestamp())
put("text", truncated)
put("servername", serverName)
put("serverip", serverIp)
put("clientip", clientIp)
put("loglevel", level)
put("traceid", traceId)
put("user", user)
}.toString()
}.getOrNull()
}
/** GlogCenter 检索要求的时间格式:yyyy-MM-dd HH:mm:ss.SSS */
private fun timestamp(): String =
SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.ROOT).format(Date())
private fun sanitize(value: String): String =
value.map { if (it.isLetterOrDigit() || it == '-' || it == '_' || it == ' ') it else ' ' }
.joinToString("").trim()
// ─── 公网 IP(主机 IP)────────────────────────────
/**
* 确保主机 IP(公网出口 IP)可用。
*
* 仅在「从未获取过 / 已超过 [PUBLIC_IP_TTL_MS] / 显式要求复核」时发起查询,
* 且同一时刻只允许一个查询在飞。查询在独立后台线程完成,绝不阻塞调用方
* (`enqueue` 可能来自主线程)。
*/
private fun ensureServerIp(force: Boolean = false) {
val age = System.currentTimeMillis() - serverIpAt
val fresh = serverIp.isNotEmpty() && !force && age < PUBLIC_IP_TTL_MS
if (fresh) return
if (!publicIpFetching.compareAndSet(false, true)) return
Thread({
try {
val ip = queryPublicIp()
if (ip.isNotEmpty()) {
serverIp = ip
serverIpAt = System.currentTimeMillis()
} else {
// 查询失败:保留旧值,但延后重试,避免每条日志都触发一次请求。
serverIpAt = System.currentTimeMillis() - PUBLIC_IP_TTL_MS + PUBLIC_IP_RETRY_MS
}
} finally {
publicIpFetching.set(false)
}
}, "mili-pubip").also { it.isDaemon = true; it.start() }
}
/**
* 等待主机 IP 就绪(最多 [timeoutMs]),供启动上报使用。
*
* 调用方必须在子线程(内部会短暂 sleep);超时未就绪则返回空串,不影响上报。
*/
fun awaitServerIp(timeoutMs: Long = 3000L): String {
ensureServerIp()
val deadline = System.currentTimeMillis() + timeoutMs
while (serverIp.isEmpty() && System.currentTimeMillis() < deadline) {
try {
Thread.sleep(100L)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
break
}
}
return serverIp
}
/** 查询公网出口 IP([PUBLIC_IP_URL] 返回纯文本 IP);失败返回空串。 */
private fun queryPublicIp(): String = try {
val connection = (URL(PUBLIC_IP_URL).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = PUBLIC_IP_TIMEOUT_MS
readTimeout = PUBLIC_IP_TIMEOUT_MS
setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
}
try {
val code = connection.responseCode
if (code in 200..299) {
val text = connection.inputStream.use { it.readBytes().toString(Charsets.UTF_8) }.trim()
// 只接受形如 IPv4/IPv6 的纯文本,长度上限 45(IPv6 最长)。
if (text.isNotEmpty() && text.length <= 45 &&
text.all { it.isLetterOrDigit() || it == '.' || it == ':' }
) {
text
} else {
""
}
} else {
""
}
} finally {
connection.disconnect()
}
} catch (e: Throwable) {
Log.w(TAG, "public ip query failed: ${e.message}")
""
}
// ─── 发送线程 ────────────────────────────────────
private fun sendLoop() {
var consecutiveFailures = 0
while (running.get()) {
try {
val body: String = try {
queue.take()
} catch (e: InterruptedException) {
break
}
// 本地留一份(远程日志不可用时,现场仍可取证)。
appendLocal(body)
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) {
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)
}
}
}
private fun post(body: String): Boolean = try {
val connection = (URL(ENDPOINT).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty("Content-Type", "application/json; charset=utf-8")
setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
}
try {
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
val code = connection.responseCode
// 读取并丢弃响应体,确保请求完成(也便于排查)。
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
runCatching { stream?.use { it.readBytes() } }
code in 200..299
} finally {
connection.disconnect()
}
} catch (e: Throwable) {
Log.w(TAG, "glog send error: ${e.message}")
false
}
private fun sleepQuietly(ms: Long) {
try {
Thread.sleep(ms)
} catch (ignored: InterruptedException) {
Thread.currentThread().interrupt()
}
}
/** 停止发送(退出时调用)。 */
fun shutdown() {
running.set(false)
senderThread?.interrupt()
senderThread = null
}
}