现场两个问题分别定位: 一、"歌曲源全部失效" —— 已确认是**源站对该出口 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 例全通过。
357 lines
14 KiB
Kotlin
357 lines
14 KiB
Kotlin
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
|
||
}
|
||
}
|