日志系统更新为 GlogCenter(HTTP): 替换原 syslog/TLS 上报

- MiliLog 重写: POST http://log.kasugano.cn/glc/v1/log/add (application/json 单条)
  字段 system/date/text/servername/serverip/loglevel/traceid/user
  date 格式 yyyy-MM-dd HH:mm:ss.SSS; 有界队列+后台线程+失败重试/退避
- init 接口保持不变, 各调用点(MainActivity/KtvJsBridge/SongOkDownloadManager)无需改动
- 新增 updateDeviceContext(): 注入设备 IP 与标识; 网络变化时自动刷新
- AGENTS.md 记录新日志系统用法与字段/时间格式约束
This commit is contained in:
zijunchiang
2026-09-12 17:24:43 +08:00
parent b3b8e6a12d
commit 2779237408
3 changed files with 138 additions and 135 deletions
+5
View File
@@ -98,4 +98,9 @@ $BT/apksigner verify 目标名.apk
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
- **曲库同步时效**:默认 **30 天**`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载) - **曲库同步时效**:默认 **30 天**`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载)
- **麦克风**`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建 - **麦克风**`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建
- **远程日志**GlogCenter HTTP 接口 `POST http://log.kasugano.cn/glc/v1/log/add`(单条 JSON,字段
`system`/`date`/`text`/`servername`/`serverip`/`loglevel`/`traceid`/`user``date` 必须为
`yyyy-MM-dd HH:mm:ss.SSS`,否则按时间检索异常)。客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),
调用点 `MiliLog.info/notice/warn/error` 保持不变;设备 IP/标识通过 `MiliLog.updateDeviceContext()` 注入,
网络变化时自动刷新
- **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403 - **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403
@@ -162,6 +162,8 @@ class MainActivity : AppCompatActivity() {
startRemoteServer() startRemoteServer()
updateMobileQrOverlay() updateMobileQrOverlay()
updateFullScreenQrOverlay() updateFullScreenQrOverlay()
// 网络/IP 变化后同步日志上报的 serverip。
refreshLogDeviceContext()
} }
main.postDelayed(this, 5000) main.postDelayed(this, 5000)
} }
@@ -459,8 +461,9 @@ class MainActivity : AppCompatActivity() {
// 初始化 API 客户端 (歌曲下载链接) // 初始化 API 客户端 (歌曲下载链接)
init("abe235a87118f6de", "080027deed4f") init("abe235a87118f6de", "080027deed4f")
playbackEngine = KtvPlaybackEngine(applicationContext) playbackEngine = KtvPlaybackEngine(applicationContext)
// 远程日志系统: 加载打包证书并后台连接 syslog 服务器, 启动即上报设备/网络信息。 // 远程日志系统(GlogCenter): 启动即上报设备/网络信息。
MiliLog.init(applicationContext) MiliLog.init(applicationContext)
refreshLogDeviceContext()
reportDeviceBootInfo() reportDeviceBootInfo()
// 歌曲源窗口/风控到期守卫: 源对陌生出口 IP 可能在短暂窗口后返回 403, // 歌曲源窗口/风控到期守卫: 源对陌生出口 IP 可能在短暂窗口后返回 403,
// 换公网 IP 即恢复。到期时弹一次提示引导用户重启光猫/路由器(重拨换 IP)。 // 换公网 IP 即恢复。到期时弹一次提示引导用户重启光猫/路由器(重拨换 IP)。
@@ -3502,6 +3505,11 @@ class MainActivity : AppCompatActivity() {
}, "ktv-heartbeat").start() }, "ktv-heartbeat").start()
} }
/** 远程日志: 把设备 IP 与设备标识同步给日志客户端(GlogCenter 的 serverip/user/traceid)。 */
private fun refreshLogDeviceContext() {
MiliLog.updateDeviceContext(ip = localIp(), deviceId = LEGACY_DEVICE_ID)
}
/** 远程日志: App 启动时上报设备信息与网络信息(异步, 尽力而为)。 */ /** 远程日志: App 启动时上报设备信息与网络信息(异步, 尽力而为)。 */
private fun reportDeviceBootInfo() { private fun reportDeviceBootInfo() {
Thread({ Thread({
+120 -130
View File
@@ -3,199 +3,189 @@ package com.local.ktv
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import java.io.BufferedOutputStream import org.json.JSONObject
import java.security.KeyStore import java.net.HttpURLConnection
import java.security.cert.CertificateFactory import java.net.URL
import java.security.cert.X509Certificate import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManagerFactory
/** /**
* 远程日志客户端 —— 复刻为内网/私有部署的 syslog 服务器(zb.kasugano.cn:514) * 远程日志客户端 —— GlogCentergotoeasy/glogcenterHTTP 接口
* *
* - 传输: TCP + TLS(信任打包的 raw/mili_log.crt 根证书, 服务器为 Let's Encrypt * 接口:`POST http://log.kasugano.cn/glc/v1/log/add``Content-Type: application/json`
* 签发链 ISRG Root X1, 故该根即可完成链校验; 连接目标域名与证书 SAN 不同, * 单条 JSON 对象(批量用 `/glc/v1/log/addBatch`,此处按单条上报)。
* 属私有部署惯例, 因此只做链校验、不做主机名校验)。
* - 格式: BSD syslog RFC 3164 —— <PRI>MMM dd HH:mm:ss HOSTNAME TAG: message
* - 策略: 单后台线程 + 有界队列; 断线自动重连; 尽力而为, 失败不阻塞业务。
* *
* 日志事件: * 字段(均为字符串,对应 GlogCenter 页面列):
* 1) App 启动时上报设备/网络信息; * system 系统名(本 App 固定 [SYSTEM]
* 2) 点歌解析歌曲源(w.w345.my / 实际下载域名)时实时上报; * date 日期时间,格式 `yyyy-MM-dd HH:mm:ss.SSS`(务必与检索格式一致)
* 3) 网络错误/接口 403/下载失败/播放失败实时上报。 * text 日志内容
* servername 主机名(设备型号)
* serverip 主机 IP(设备局域网 IP)
* loglevel 日志级别(INFO / WARN / ERROR
* traceid 追踪码(此处用设备标识)
* user 用户(此处用设备标识)
*
* 策略:单后台线程 + 有界队列;发送失败重试一次,连续失败退避;尽力而为,不阻塞业务。
* 日志事件:启动设备/网络信息、点歌命中的源与结果、接口 403/网络错误、下载/播放失败。
*/ */
object MiliLog { object MiliLog {
private const val TAG = "MiliLog" private const val TAG = "MiliLog"
private const val HOST = "zb.kasugano.cn" private const val ENDPOINT = "http://log.kasugano.cn/glc/v1/log/add"
private const val PORT = 514
private const val TAG_NAME = "MaidongKTV" /** GlogCenter 页面上的「系统名」,用于区分应用。 */
private const val FACILITY = 16 // local0 private const val SYSTEM = "maidongktv"
private const val MAX_MSG_BYTES = 900 private const val MAX_TEXT_LEN = 900
private const val MAX_QUEUE = 300 private const val MAX_QUEUE = 300
private const val CONNECT_TIMEOUT_MS = 8000 private const val CONNECT_TIMEOUT_MS = 8000
private const val SOCKET_TIMEOUT_MS = 10000 private const val READ_TIMEOUT_MS = 8000
private const val RETRY_SLEEP_MS = 10_000L private const val RETRY_SLEEP_MS = 1500L
private const val FAILURE_BACKOFF_MS = 10_000L
// RFC 3164 severity
private const val SEV_ERROR = 3
private const val SEV_WARNING = 4
private const val SEV_NOTICE = 5
private const val SEV_INFO = 6
private val started = AtomicBoolean(false) private val started = AtomicBoolean(false)
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
private val queue = LinkedBlockingQueue<String>(MAX_QUEUE) private val queue = LinkedBlockingQueue<String>(MAX_QUEUE)
private var sslContext: SSLContext? = null
private var hostname = "android"
@Volatile private var socket: SSLSocket? = null
private var senderThread: Thread? = null private var senderThread: Thread? = null
/** 用 app context 初始化(加载打包证书、读取设备标识)。可重复调用。 */ @Volatile private var serverName: String = "android"
@Volatile private var serverIp: String = ""
@Volatile private var traceId: String = ""
@Volatile private var user: String = ""
/** 用 app context 初始化并启动发送线程。可重复调用。 */
fun init(context: Context) { fun init(context: Context) {
if (started.getAndSet(true)) return if (started.getAndSet(true)) return
val appContext = context.applicationContext serverName = sanitize(Build.MODEL).ifEmpty { "android" }
hostname = sanitize(Build.MODEL).ifEmpty { "android" }
sslContext = buildSslContext(appContext)
running.set(true) running.set(true)
senderThread = Thread({ sendLoop() }, "mili-syslog").also { it.isDaemon = true; it.start() } senderThread = Thread({ sendLoop() }, "mili-glog").also { it.isDaemon = true; it.start() }
} }
private fun sanitize(s: String): String = /** 更新上报用的设备网络/身份字段(IP 变化或重新登录时调用)。 */
s.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else ' ' } fun updateDeviceContext(ip: String? = null, deviceId: String? = null) {
.joinToString("").trim().replace(Regex("\\s+"), "-") if (!ip.isNullOrBlank()) serverIp = ip
if (!deviceId.isNullOrBlank()) {
private fun buildSslContext(context: Context): SSLContext? = try { user = deviceId
val cf = CertificateFactory.getInstance("X.509") traceId = deviceId
val cert = context.resources.openRawResource(R.raw.mili_log).use {
cf.generateCertificate(it) as X509Certificate
} }
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null)
keyStore.setCertificateEntry("mili_log", cert)
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(keyStore)
SSLContext.getInstance("TLS").apply { init(null, tmf.trustManagers, null) }
} catch (e: Throwable) {
Log.e(TAG, "buildSslContext failed", e)
null
} }
// ─── 对外上报接口 ──────────────────────────────── 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)
fun info(msg: String) = enqueue(SEV_INFO, msg) private fun enqueue(level: String, msg: String) {
fun notice(msg: String) = enqueue(SEV_NOTICE, msg) if (!started.get()) return
fun warn(msg: String) = enqueue(SEV_WARNING, msg) val body = buildBody(level, msg) ?: return
fun error(msg: String) = enqueue(SEV_ERROR, msg) // 队列满时丢最旧,保证新日志能进队。
if (!queue.offer(body)) {
private fun enqueue(severity: Int, msg: String) {
if (!started.get() || sslContext == null) return
val line = format(severity, msg)
if (line == null) return
// 队列满时丢最旧, 保证新日志能进队。
if (!queue.offer(line)) {
queue.poll() queue.poll()
queue.offer(line) queue.offer(body)
} }
} }
private fun format(severity: Int, msg: String): String? { private fun buildBody(level: String, msg: String): String? {
val body = msg.replace(Regex("[\\r\\n]+"), " ").trim() val text = msg.replace(Regex("[\\r\\n]+"), " ").trim()
if (body.isEmpty()) return null if (text.isEmpty()) return null
val truncated = if (body.toByteArray(Charsets.UTF_8).size > MAX_MSG_BYTES) { val truncated = if (text.toByteArray(Charsets.UTF_8).size > MAX_TEXT_LEN) {
body.take(MAX_MSG_BYTES) text.take(MAX_TEXT_LEN)
} else body } else {
val now = Calendar.getInstance() text
val months = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val day = String.format(Locale.US, "%2d", now.get(Calendar.DAY_OF_MONTH))
val time = String.format(Locale.US, "%02d:%02d:%02d",
now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), now.get(Calendar.SECOND))
val pri = FACILITY * 8 + severity
// RFC3164: <PRI>TIMESTAMP HOSTNAME TAG: MSG (TAG 不含空格)
return "<$pri>${months[now.get(Calendar.MONTH)]} $day $time $hostname $TAG_NAME: $truncated"
} }
return runCatching {
JSONObject().apply {
put("system", SYSTEM)
put("date", timestamp())
put("text", truncated)
put("servername", serverName)
put("serverip", serverIp)
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()
// ─── 发送线程 ──────────────────────────────────── // ─── 发送线程 ────────────────────────────────────
private fun sendLoop() { private fun sendLoop() {
var consecutiveFailures = 0 var consecutiveFailures = 0
while (running.get()) { while (running.get()) {
val line: String = try { val body: String = try {
queue.take() queue.take()
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
break break
} }
var sent = false var sent = post(body)
var attempt = 0 if (!sent) {
while (!sent && attempt < 2) { sleepQuietly(RETRY_SLEEP_MS)
attempt++ sent = post(body)
try { }
val s = connectOrReuse() if (sent) {
val out = BufferedOutputStream(s.getOutputStream())
out.write(line.toByteArray(Charsets.UTF_8))
out.write('\n'.code)
out.flush()
sent = true
consecutiveFailures = 0 consecutiveFailures = 0
// 同一连接顺手把队列里已积压的消息发完。 // 顺手把已积压的日志发完。
var next: String? = queue.poll() var next: String? = queue.poll()
while (next != null) { while (next != null) {
out.write(next.toByteArray(Charsets.UTF_8)) post(next)
out.write('\n'.code)
out.flush()
next = queue.poll() next = queue.poll()
} }
} catch (e: Throwable) { } else {
closeSocket()
if (attempt == 1) {
Log.w(TAG, "syslog send failed (${e.message}), retrying...")
try { Thread.sleep(1500) } catch (ie: InterruptedException) { break }
}
}
}
if (!sent) {
consecutiveFailures++ consecutiveFailures++
Log.w(TAG, "syslog send dropped: $line") Log.w(TAG, "glog send dropped (failures=$consecutiveFailures): ${body.take(120)}")
if (consecutiveFailures >= 3) { if (consecutiveFailures >= 3) {
consecutiveFailures = 0 consecutiveFailures = 0
try { Thread.sleep(RETRY_SLEEP_MS) } catch (ie: InterruptedException) { break } sleepQuietly(FAILURE_BACKOFF_MS)
} }
} }
} }
closeSocket()
} }
private fun connectOrReuse(): SSLSocket { private fun post(body: String): Boolean = try {
val existing = socket val connection = (URL(ENDPOINT).openConnection() as HttpURLConnection).apply {
if (existing != null && existing.isConnected && !existing.isClosed && !existing.isOutputShutdown) { requestMethod = "POST"
return existing 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}")
} }
val context = sslContext ?: throw IllegalStateException("SSL context not ready") try {
val raw = context.socketFactory.createSocket() as SSLSocket connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
raw.connect(java.net.InetSocketAddress(HOST, PORT), CONNECT_TIMEOUT_MS) val code = connection.responseCode
raw.soTimeout = SOCKET_TIMEOUT_MS // 读取并丢弃响应体,确保请求完成(也便于排查)。
raw.startHandshake() val stream = if (code in 200..299) connection.inputStream else connection.errorStream
socket = raw runCatching { stream?.use { it.readBytes() } }
Log.i(TAG, "syslog connected to $HOST:$PORT") code in 200..299
return raw } finally {
connection.disconnect()
}
} catch (e: Throwable) {
Log.w(TAG, "glog send error: ${e.message}")
false
} }
private fun closeSocket() { private fun sleepQuietly(ms: Long) {
try { socket?.close() } catch (ignored: Exception) {} try {
socket = null Thread.sleep(ms)
} catch (ignored: InterruptedException) {
Thread.currentThread().interrupt()
}
} }
/** 供测试/关闭时调用。 */ /** 停止发送(退出时调用。 */
fun shutdown() { fun shutdown() {
running.set(false) running.set(false)
senderThread?.interrupt() senderThread?.interrupt()
senderThread = null senderThread = null
closeSocket()
} }
} }