Files
JChatGPT/src/main/kotlin/data/TokenUsageStore.kt
T

1011 lines
44 KiB
Kotlin

package top.jie65535.mirai.data
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.sqlite.SQLiteConfig
import java.io.File
import java.sql.Connection
import java.sql.DriverManager
import java.sql.ResultSet
import java.security.MessageDigest
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* SQLite model-usage ledger. New calls are stored individually; old daily JSON
* aggregates are imported as rows marked [TokenUsageRecord.detailed] = false.
*/
object TokenUsageStore {
private const val SCHEMA_VERSION = 2
private const val BUSY_TIMEOUT_MS = 30_000
private const val DATABASE_NAME = "chat-history.sqlite"
private const val LEGACY_FILE_NAME = "token_usage.json"
private const val LEGACY_FINGERPRINT_KEY = "legacy_json_sha256"
private const val TOP_LIMIT = 5
private const val MAX_RANKING_LIMIT = 100
private val lifecycleLock = Any()
private val writeLock = Any()
private val dateFmt = DateTimeFormatter.ISO_LOCAL_DATE
private val zone = ZoneId.systemDefault()
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val legacySerializer = ListSerializer(TokenUsageDailyRecord.serializer())
@Volatile
private var initialized = false
private lateinit var databaseFile: File
private var writeConnection: Connection? = null
private var warningLogger: ((String, Throwable?) -> Unit)? = null
val isAvailable: Boolean
get() = initialized
fun init(
dataFolder: File,
onWarning: (String, Throwable?) -> Unit = { _, _ -> },
) {
synchronized(lifecycleLock) {
if (initialized) return
Class.forName("org.sqlite.JDBC")
dataFolder.mkdirs()
databaseFile = dataFolder.resolve(DATABASE_NAME)
warningLogger = onWarning
val connection = openConnection(databaseFile)
try {
configureWriteConnection(connection)
createSchema(connection)
writeConnection = connection
initialized = true
importLegacyJson(dataFolder.resolve(LEGACY_FILE_NAME))
} catch (cause: Throwable) {
connection.close()
warningLogger = null
throw cause
}
}
}
fun close() {
synchronized(lifecycleLock) {
if (!initialized) return
synchronized(writeLock) {
writeConnection?.let { connection ->
runCatching {
connection.createStatement().use { it.execute("PRAGMA wal_checkpoint(TRUNCATE)") }
}.onFailure { warn("Token SQLite WAL checkpoint 失败", it) }
connection.close()
}
writeConnection = null
initialized = false
warningLogger = null
}
}
}
/** Records one successful chat-model response. Storage failures are non-fatal. */
fun record(
timestamp: Long,
botId: Long,
userId: Long,
userNickname: String,
groupId: Long?,
groupName: String?,
endpointLabel: String?,
apiBaseUrl: String?,
model: String?,
promptTokens: Int,
completionTokens: Int,
totalTokens: Int,
cachedTokens: Int,
) {
recordUsage(
ModelUsageEvent(
timestamp = timestamp,
botId = botId,
userId = userId,
userNickname = userNickname,
groupId = groupId,
groupName = groupName,
endpointLabel = endpointLabel,
modelAlias = endpointLabel,
provider = providerFor(apiBaseUrl),
model = model,
usageKind = "chat",
inputUnits = promptTokens.toLong().coerceAtLeast(0),
outputUnits = completionTokens.toLong().coerceAtLeast(0),
totalUnits = totalTokens.toLong().coerceAtLeast(0),
promptTokens = promptTokens.toLong().coerceAtLeast(0),
completionTokens = completionTokens.toLong().coerceAtLeast(0),
totalTokens = totalTokens.toLong().coerceAtLeast(0),
cachedTokens = cachedTokens.toLong().coerceAtLeast(0),
)
)
}
fun recordUsage(event: ModelUsageEvent) {
if (!initialized) return
runCatching {
withWriteConnection { connection ->
connection.prepareStatement(
"""
INSERT INTO token_usage_record(
occurred_at, usage_date, bot_id, user_id, user_nickname,
group_id, group_name, endpoint_label, model_alias, provider, model,
usage_kind, unit, input_units, output_units, total_units,
prompt_tokens, completion_tokens, total_tokens, cached_tokens,
call_count, detailed
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
""".trimIndent()
).use { statement ->
statement.setLong(1, event.timestamp)
statement.setString(2, dateFor(event.timestamp))
statement.setLong(3, event.botId)
statement.setLong(4, event.userId)
statement.setString(5, sanitizeNickname(event.userNickname))
if (event.groupId == null) statement.setNull(6, java.sql.Types.INTEGER) else statement.setLong(6, event.groupId)
if (event.groupName == null) statement.setNull(7, java.sql.Types.VARCHAR)
else statement.setString(7, sanitizeNickname(event.groupName))
statement.setString(8, event.endpointLabel?.takeIf(String::isNotBlank))
statement.setString(9, event.modelAlias?.takeIf(String::isNotBlank))
statement.setString(10, event.provider?.takeIf(String::isNotBlank))
statement.setString(11, event.model?.takeIf(String::isNotBlank))
statement.setString(12, event.usageKind.ifBlank { "other" })
statement.setString(13, event.unit.ifBlank { "units" })
statement.setLong(14, event.inputUnits.coerceAtLeast(0))
statement.setLong(15, event.outputUnits.coerceAtLeast(0))
statement.setLong(16, event.totalUnits.coerceAtLeast(0))
statement.setLong(17, event.promptTokens.coerceAtLeast(0))
statement.setLong(18, event.completionTokens.coerceAtLeast(0))
statement.setLong(19, event.totalTokens.coerceAtLeast(0))
statement.setLong(20, event.cachedTokens.coerceAtLeast(0))
statement.executeUpdate()
}
}
}.onFailure { warn("保存模型用量记录失败", it) }
}
fun summary(
startDate: String,
endDate: String = LocalDate.now(zone).format(dateFmt),
botId: Long? = null,
userId: Long? = null,
groupId: Long? = null,
privateOnly: Boolean = false,
model: String? = null,
usageKind: String? = null,
rankingLimit: Int = TOP_LIMIT,
): TokenUsageSummary {
check(initialized) { "Token SQLite 尚未初始化" }
require(groupId == null || !privateOnly) { "groupId and privateOnly cannot be used together" }
require(rankingLimit > 0) { "rankingLimit must be positive" }
val normalizedRankingLimit = rankingLimit.coerceAtMost(MAX_RANKING_LIMIT)
val filter = Filter(
startDate,
endDate,
botId,
userId,
groupId,
privateOnly,
model?.trim()?.takeIf(String::isNotBlank),
usageKind?.trim()?.takeIf(String::isNotBlank),
)
return openReadConnection(databaseFile).use { connection ->
connection.autoCommit = false
try {
val aggregate = queryAggregate(connection, filter, tokenOnly = true)
val allAggregate = queryAggregate(connection, filter, tokenOnly = false)
val daily = queryDaily(connection, filter)
val topUsers = queryTopUsers(connection, filter, normalizedRankingLimit)
val topGroups = queryTopGroups(connection, filter, normalizedRankingLimit)
val models = queryModels(connection, filter, normalizedRankingLimit)
val breakdown = queryBreakdown(connection, filter, normalizedRankingLimit)
val userUsage = queryUserUsage(connection, filter, normalizedRankingLimit)
val usageDaily = queryUsageDaily(connection, filter)
TokenUsageSummary(
promptTokens = aggregate.promptTokens,
completionTokens = aggregate.completionTokens,
totalTokens = aggregate.totalTokens,
cachedTokens = aggregate.cachedTokens,
callCount = aggregate.callCount,
activeUsers = aggregate.activeUsers,
todayTotal = queryTodayTotal(connection, filter),
daily = daily,
topUsers = topUsers,
topGroups = topGroups,
models = models,
allCallCount = allAggregate.callCount,
allActiveUsers = allAggregate.activeUsers,
breakdown = breakdown,
userUsage = userUsage,
usageDaily = usageDaily,
)
} finally {
connection.rollback()
}
}
}
fun recent(
limit: Int = 20,
startDate: String? = null,
endDate: String? = null,
botId: Long? = null,
userId: Long? = null,
groupId: Long? = null,
privateOnly: Boolean = false,
model: String? = null,
usageKind: String? = null,
): List<TokenUsageRecord> {
check(initialized) { "Token SQLite 尚未初始化" }
require(limit > 0) { "limit must be positive" }
require(groupId == null || !privateOnly) { "groupId and privateOnly cannot be used together" }
val filter = Filter(
startDate,
endDate,
botId,
userId,
groupId,
privateOnly,
model?.trim()?.takeIf(String::isNotBlank),
usageKind?.trim()?.takeIf(String::isNotBlank),
)
return openReadConnection(databaseFile).use { connection ->
val (where, args) = whereClause(filter)
val sql = "SELECT * FROM token_usage_record WHERE $where ORDER BY occurred_at DESC, id DESC LIMIT ?"
connection.prepareStatement(sql).use { statement ->
bind(statement, args)
statement.setInt(args.size + 1, limit.coerceAtMost(100))
statement.executeQuery().use { results ->
buildList { while (results.next()) add(results.toTokenUsageRecord()) }
}
}
}
}
/** Best-effort access for the command during a partial startup. */
fun hasAny(startDate: String, endDate: String = LocalDate.now(zone).format(dateFmt)): Boolean {
if (!initialized) return false
return openReadConnection(databaseFile).use { connection ->
val (where, args) = whereClause(Filter(startDate, endDate, null, null, null, false, null, null))
connection.prepareStatement("SELECT 1 FROM token_usage_record WHERE $where LIMIT 1").use { statement ->
bind(statement, args)
statement.executeQuery().use(ResultSet::next)
}
}
}
private data class Filter(
val startDate: String?,
val endDate: String?,
val botId: Long?,
val userId: Long?,
val groupId: Long?,
val privateOnly: Boolean,
val model: String?,
val usageKind: String?,
)
private data class Aggregate(
val promptTokens: Long,
val completionTokens: Long,
val totalTokens: Long,
val cachedTokens: Long,
val callCount: Int,
val activeUsers: Int,
)
private data class UserUsageAggregate(
val userId: Long,
val usageKind: String,
val unit: String,
val totalUnits: Long,
val callCount: Int,
)
private data class LegacyImportState(
val occurredAt: Long,
val date: String,
val userId: Long,
val userNickname: String,
val groupId: Long?,
val groupName: String?,
val inputUnits: Long,
val outputUnits: Long,
val totalUnits: Long,
val promptTokens: Long,
val completionTokens: Long,
val totalTokens: Long,
val cachedTokens: Long,
val callCount: Int,
)
private fun queryAggregate(connection: Connection, filter: Filter, tokenOnly: Boolean): Aggregate {
val (where, args) = whereClause(filter, tokenOnly = tokenOnly)
val sql = """
SELECT COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0),
COALESCE(SUM(total_tokens), 0), COALESCE(SUM(cached_tokens), 0),
COALESCE(SUM(call_count), 0),
COUNT(DISTINCT CASE WHEN user_id > 0 THEN user_id END)
FROM token_usage_record WHERE $where
""".trimIndent()
return connection.prepareStatement(sql).use { statement ->
bind(statement, args)
statement.executeQuery().use { results ->
check(results.next())
Aggregate(
promptTokens = results.getLong(1),
completionTokens = results.getLong(2),
totalTokens = results.getLong(3),
cachedTokens = results.getLong(4),
callCount = results.getLong(5).toInt(),
activeUsers = results.getInt(6),
)
}
}
}
private fun queryTodayTotal(connection: Connection, filter: Filter): Long {
val today = LocalDate.now(zone).format(dateFmt)
if (filter.startDate != null && today < filter.startDate || filter.endDate != null && today > filter.endDate) return 0
val todayFilter = filter.copy(startDate = today, endDate = today)
val (where, args) = whereClause(todayFilter, tokenOnly = true)
return connection.prepareStatement("SELECT COALESCE(SUM(total_tokens), 0) FROM token_usage_record WHERE $where")
.use { statement ->
bind(statement, args)
statement.executeQuery().use { results -> check(results.next()); results.getLong(1) }
}
}
private fun queryDaily(connection: Connection, filter: Filter): List<TokenUsageDailyTotal> {
val (where, args) = whereClause(filter, tokenOnly = true)
return connection.prepareStatement(
"SELECT usage_date, SUM(total_tokens) FROM token_usage_record WHERE $where GROUP BY usage_date ORDER BY usage_date"
).use { statement ->
bind(statement, args)
statement.executeQuery().use { results ->
buildList {
while (results.next()) add(TokenUsageDailyTotal(results.getString(1), results.getLong(2)))
}
}
}
}
private fun queryTopUsers(
connection: Connection,
filter: Filter,
limit: Int,
): List<TokenUsageRanking> {
val (where, args) = whereClause(filter, tokenOnly = true)
val totals = connection.prepareStatement(
"SELECT user_id, SUM(total_tokens) AS total FROM token_usage_record " +
"WHERE $where AND user_id > 0 GROUP BY user_id ORDER BY total DESC LIMIT ?"
).use { statement ->
bind(statement, args)
statement.setInt(args.size + 1, limit)
statement.executeQuery().use { results ->
buildList { while (results.next()) add(results.getLong("user_id") to results.getLong("total")) }
}
}
val (nameWhere, nameArgs) = whereClause(filter.copy(model = null, usageKind = null))
return totals.map { (id, total) ->
val name = connection.prepareStatement(
"SELECT user_nickname FROM token_usage_record WHERE $nameWhere AND user_id = ? " +
"AND user_nickname <> '' ORDER BY occurred_at DESC, id DESC LIMIT 1"
).use { statement ->
bind(statement, nameArgs)
statement.setLong(nameArgs.size + 1, id)
statement.executeQuery().use { results -> if (results.next()) results.getString(1).orEmpty() else "" }
}
TokenUsageRanking(id, name, total)
}
}
private fun queryTopGroups(
connection: Connection,
filter: Filter,
limit: Int,
): List<TokenUsageRanking> {
val (where, args) = whereClause(filter, requireGroup = true, tokenOnly = true)
val totals = connection.prepareStatement(
"SELECT group_id, SUM(total_tokens) AS total FROM token_usage_record WHERE $where GROUP BY group_id ORDER BY total DESC LIMIT ?"
).use { statement ->
bind(statement, args)
statement.setInt(args.size + 1, limit)
statement.executeQuery().use { results ->
buildList { while (results.next()) add(results.getLong("group_id") to results.getLong("total")) }
}
}
val (nameWhere, nameArgs) = whereClause(
filter.copy(model = null, usageKind = null),
requireGroup = true,
)
return totals.map { (id, total) ->
val name = connection.prepareStatement(
"SELECT group_name FROM token_usage_record WHERE $nameWhere AND group_id = ? " +
"AND group_name IS NOT NULL AND group_name <> '' ORDER BY occurred_at DESC, id DESC LIMIT 1"
).use { statement ->
bind(statement, nameArgs)
statement.setLong(nameArgs.size + 1, id)
statement.executeQuery().use { results -> if (results.next()) results.getString(1).orEmpty() else "" }
}
TokenUsageRanking(id, name, total)
}
}
private fun queryModels(
connection: Connection,
filter: Filter,
limit: Int,
): List<TokenUsageModelTotal> {
val (where, args) = whereClause(filter, tokenOnly = true)
return connection.prepareStatement(
"""
SELECT COALESCE(provider, ''), COALESCE(model, ''), SUM(total_units), SUM(call_count)
FROM token_usage_record WHERE $where
GROUP BY provider, model ORDER BY SUM(total_tokens) DESC LIMIT ?
""".trimIndent()
).use { statement ->
bind(statement, args)
statement.setInt(args.size + 1, limit)
statement.executeQuery().use { results ->
buildList {
while (results.next()) {
add(
TokenUsageModelTotal(
provider = results.getString(1).ifBlank { "unknown" },
model = results.getString(2).ifBlank { "unknown" },
totalTokens = results.getLong(3),
callCount = results.getLong(4).toInt(),
)
)
}
}
}
}
}
private fun queryBreakdown(
connection: Connection,
filter: Filter,
limit: Int,
): List<TokenUsageBreakdown> {
val (where, args) = whereClause(filter)
return connection.prepareStatement(
"""
SELECT COALESCE(provider, ''), COALESCE(model, ''),
COALESCE(usage_kind, 'other'), COALESCE(unit, 'units'),
SUM(input_units), SUM(output_units), SUM(total_units), SUM(call_count)
FROM token_usage_record WHERE $where
GROUP BY provider, model, usage_kind, unit
ORDER BY SUM(total_units) DESC LIMIT ?
""".trimIndent()
).use { statement ->
bind(statement, args)
statement.setInt(args.size + 1, limit)
statement.executeQuery().use { results ->
buildList {
while (results.next()) {
add(
TokenUsageBreakdown(
provider = results.getString(1).ifBlank { "unknown" },
model = results.getString(2).ifBlank { "unknown" },
usageKind = results.getString(3).ifBlank { "other" },
unit = results.getString(4).ifBlank { "units" },
inputUnits = results.getLong(5),
outputUnits = results.getLong(6),
totalUnits = results.getLong(7),
callCount = results.getLong(8).toInt(),
)
)
}
}
}
}
}
private fun queryUserUsage(
connection: Connection,
filter: Filter,
limit: Int,
): List<ModelUsageUserTotal> {
val (where, args) = whereClause(filter)
val totals = connection.prepareStatement(
"""
SELECT user_id, COALESCE(usage_kind, 'other'), COALESCE(unit, 'units'),
SUM(total_units), SUM(call_count)
FROM token_usage_record
WHERE $where AND user_id > 0
GROUP BY user_id, usage_kind, unit
ORDER BY usage_kind, unit, SUM(total_units) DESC
LIMIT ?
""".trimIndent()
).use { statement ->
bind(statement, args)
statement.setInt(args.size + 1, limit)
statement.executeQuery().use { results ->
buildList {
while (results.next()) {
add(
UserUsageAggregate(
userId = results.getLong(1),
usageKind = results.getString(2),
unit = results.getString(3),
totalUnits = results.getLong(4),
callCount = results.getLong(5).toInt(),
)
)
}
}
}
}
val (nameWhere, nameArgs) = whereClause(filter.copy(model = null, usageKind = null))
return totals.map { row ->
val name = connection.prepareStatement(
"SELECT user_nickname FROM token_usage_record WHERE $nameWhere AND user_id = ? AND user_nickname <> '' " +
"ORDER BY occurred_at DESC, id DESC LIMIT 1"
).use { statement ->
bind(statement, nameArgs)
statement.setLong(nameArgs.size + 1, row.userId)
statement.executeQuery().use { results -> if (results.next()) results.getString(1).orEmpty() else "" }
}
ModelUsageUserTotal(
userId = row.userId,
name = name,
usageKind = row.usageKind,
unit = row.unit,
totalUnits = row.totalUnits,
callCount = row.callCount,
)
}
}
private fun queryUsageDaily(
connection: Connection,
filter: Filter,
): List<ModelUsageDailyTotal> {
val (where, args) = whereClause(filter)
return connection.prepareStatement(
"""
SELECT usage_date, COALESCE(usage_kind, 'other'), COALESCE(unit, 'units'),
SUM(total_units), SUM(call_count)
FROM token_usage_record
WHERE $where
GROUP BY usage_date, usage_kind, unit
ORDER BY usage_date, usage_kind, unit
""".trimIndent()
).use { statement ->
bind(statement, args)
statement.executeQuery().use { results ->
buildList {
while (results.next()) {
add(
ModelUsageDailyTotal(
date = results.getString(1),
usageKind = results.getString(2),
unit = results.getString(3),
totalUnits = results.getLong(4),
callCount = results.getLong(5).toInt(),
)
)
}
}
}
}
}
private fun whereClause(
filter: Filter,
requireGroup: Boolean = false,
tokenOnly: Boolean = false,
): Pair<String, List<Any>> {
val conditions = mutableListOf<String>()
val args = mutableListOf<Any>()
if (tokenOnly) conditions += "unit = 'tokens'"
filter.startDate?.let { conditions += "usage_date >= ?"; args += it }
filter.endDate?.let { conditions += "usage_date <= ?"; args += it }
filter.botId?.let { conditions += "bot_id = ?"; args += it }
filter.userId?.let { conditions += "user_id = ?"; args += it }
if (requireGroup) conditions += "group_id IS NOT NULL"
if (filter.privateOnly) conditions += "group_id IS NULL"
filter.groupId?.let { conditions += "group_id = ?"; args += it }
filter.model?.let { conditions += "model = ?"; args += it }
filter.usageKind?.let { conditions += "usage_kind = ?"; args += it }
return (conditions.takeIf { it.isNotEmpty() }?.joinToString(" AND ") ?: "1 = 1") to args
}
private fun bind(statement: java.sql.PreparedStatement, args: List<Any>) {
args.forEachIndexed { index, value ->
when (value) {
is Long -> statement.setLong(index + 1, value)
is Int -> statement.setInt(index + 1, value)
else -> statement.setString(index + 1, value.toString())
}
}
}
private fun importLegacyJson(file: File) {
if (!file.isFile || file.length() == 0L) return
val fingerprint = runCatching { sha256(file) }.getOrElse { cause ->
warn("计算旧 Token 使用文件指纹失败,已跳过迁移", cause)
return
}
val fingerprintMatches = runCatching {
withWriteConnection { connection -> readMeta(connection, LEGACY_FINGERPRINT_KEY) == fingerprint }
}.getOrElse { cause ->
warn("读取旧 Token 迁移状态失败,将继续校验现有数据", cause)
false
}
if (fingerprintMatches) return
val records = try {
json.decodeFromString(legacySerializer, file.readText())
} catch (cause: Throwable) {
val backup = File(file.parentFile, "${file.name}.broken-${System.currentTimeMillis()}")
runCatching { file.copyTo(backup, overwrite = true) }
warn("读取旧 Token 使用文件失败,已保留原文件并跳过迁移", cause)
return
}
if (records.isEmpty()) return
runCatching {
var imported = false
withWriteConnection { connection ->
val oldAutoCommit = connection.autoCommit
connection.autoCommit = false
try {
if (!legacyRowsMatch(connection, records)) {
connection.prepareStatement(
"""
INSERT INTO token_usage_record(
occurred_at, usage_date, user_id, user_nickname, group_id, group_name,
usage_kind, unit, input_units, output_units, total_units,
prompt_tokens, completion_tokens, total_tokens, cached_tokens,
call_count, detailed, legacy_key
) VALUES (?, ?, ?, ?, ?, ?, 'chat', 'tokens', ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
ON CONFLICT(legacy_key) DO UPDATE SET
occurred_at = excluded.occurred_at,
usage_date = excluded.usage_date,
user_nickname = excluded.user_nickname,
group_name = excluded.group_name,
usage_kind = excluded.usage_kind,
unit = excluded.unit,
input_units = excluded.input_units,
output_units = excluded.output_units,
total_units = excluded.total_units,
prompt_tokens = excluded.prompt_tokens,
completion_tokens = excluded.completion_tokens,
total_tokens = excluded.total_tokens,
cached_tokens = excluded.cached_tokens,
call_count = excluded.call_count
""".trimIndent()
).use { statement ->
records.forEach { record ->
val state = record.toLegacyImportState()
statement.setLong(1, state.occurredAt)
statement.setString(2, state.date)
statement.setLong(3, state.userId)
statement.setString(4, state.userNickname)
if (state.groupId == null) statement.setNull(5, java.sql.Types.INTEGER)
else statement.setLong(5, state.groupId)
if (state.groupName == null) statement.setNull(6, java.sql.Types.VARCHAR)
else statement.setString(6, state.groupName)
statement.setLong(7, state.inputUnits)
statement.setLong(8, state.outputUnits)
statement.setLong(9, state.totalUnits)
statement.setLong(10, state.promptTokens)
statement.setLong(11, state.completionTokens)
statement.setLong(12, state.totalTokens)
statement.setLong(13, state.cachedTokens)
statement.setInt(14, state.callCount)
statement.setString(15, legacyKey(record))
statement.addBatch()
}
statement.executeBatch()
}
imported = true
}
writeMeta(connection, LEGACY_FINGERPRINT_KEY, fingerprint)
connection.commit()
} catch (cause: Throwable) {
connection.rollback()
throw cause
} finally {
connection.autoCommit = oldAutoCommit
}
}
if (imported) warn("已将 ${records.size} 条旧 Token 聚合记录迁移到 SQLite", null)
}.onFailure { warn("迁移旧 Token 使用记录失败,保留原 JSON 以便下次重试", it) }
}
private fun legacyRowsMatch(connection: Connection, records: List<TokenUsageDailyRecord>): Boolean {
val expected = records.associate { legacyKey(it) to it.toLegacyImportState() }
val actual = connection.prepareStatement(
"""
SELECT legacy_key, occurred_at, usage_date, user_id, user_nickname, group_id, group_name,
input_units, output_units, total_units, prompt_tokens, completion_tokens,
total_tokens, cached_tokens, call_count
FROM token_usage_record WHERE legacy_key IS NOT NULL
""".trimIndent()
).use { statement ->
statement.executeQuery().use { results ->
buildMap {
while (results.next()) {
put(
results.getString("legacy_key"),
LegacyImportState(
occurredAt = results.getLong("occurred_at"),
date = results.getString("usage_date"),
userId = results.getLong("user_id"),
userNickname = results.getString("user_nickname").orEmpty(),
groupId = results.getLong("group_id").takeUnless { results.wasNull() },
groupName = results.getString("group_name"),
inputUnits = results.getLong("input_units"),
outputUnits = results.getLong("output_units"),
totalUnits = results.getLong("total_units"),
promptTokens = results.getLong("prompt_tokens"),
completionTokens = results.getLong("completion_tokens"),
totalTokens = results.getLong("total_tokens"),
cachedTokens = results.getLong("cached_tokens"),
callCount = results.getInt("call_count"),
)
)
}
}
}
}
return actual == expected
}
private fun TokenUsageDailyRecord.toLegacyImportState(): LegacyImportState = LegacyImportState(
occurredAt = LocalDate.parse(date, dateFmt).atStartOfDay(zone).toEpochSecond(),
date = date,
userId = userId,
userNickname = sanitizeNickname(userNickname),
groupId = groupId,
groupName = groupName?.let(::sanitizeNickname),
inputUnits = promptTokens.coerceAtLeast(0),
outputUnits = completionTokens.coerceAtLeast(0),
totalUnits = totalTokens.coerceAtLeast(0),
promptTokens = promptTokens.coerceAtLeast(0),
completionTokens = completionTokens.coerceAtLeast(0),
totalTokens = totalTokens.coerceAtLeast(0),
cachedTokens = cachedTokens.coerceAtLeast(0),
callCount = callCount.coerceAtLeast(0),
)
private fun readMeta(connection: Connection, key: String): String? =
connection.prepareStatement("SELECT value FROM token_usage_meta WHERE key = ?").use { statement ->
statement.setString(1, key)
statement.executeQuery().use { results -> if (results.next()) results.getString(1) else null }
}
private fun writeMeta(connection: Connection, key: String, value: String) {
connection.prepareStatement(
"INSERT INTO token_usage_meta(key, value) VALUES (?, ?) " +
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
).use { statement ->
statement.setString(1, key)
statement.setString(2, value)
statement.executeUpdate()
}
}
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
val hex = "0123456789abcdef"
return buildString(64) {
digest.digest().forEach { byte ->
val value = byte.toInt() and 0xff
append(hex[value ushr 4])
append(hex[value and 0x0f])
}
}
}
private fun legacyKey(record: TokenUsageDailyRecord): String =
listOf(record.date, record.userId, record.groupId ?: "private").joinToString("|")
private fun dateFor(timestamp: Long): String =
LocalDate.ofInstant(Instant.ofEpochSecond(timestamp), zone).format(dateFmt)
private fun providerFor(apiBaseUrl: String?): String? {
val host = runCatching { apiBaseUrl?.trim()?.let(java.net.URI::create)?.host?.lowercase() }.getOrNull() ?: return null
return when {
host == "api.deepseek.com" || host.endsWith(".deepseek.com") -> "deepseek"
else -> host
}
}
private fun sanitizeNickname(s: String): String {
if (s.isEmpty()) return s
return buildString(s.length) {
for (c in s) {
if (c == ' ' || (!c.isISOControl() && c.category != CharCategory.FORMAT)) append(c)
else append(' ')
}
}.trim().replace(Regex(" {2,}"), " ")
}
private fun <T> withWriteConnection(block: (Connection) -> T): T {
synchronized(writeLock) {
check(initialized) { "Token SQLite 尚未初始化" }
val connection = writeConnection?.takeUnless(Connection::isClosed)
?: openConnection(databaseFile).also {
configureWriteConnection(it)
writeConnection = it
}
return block(connection)
}
}
private fun createSchema(connection: Connection) {
val oldAutoCommit = connection.autoCommit
connection.autoCommit = false
try {
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS token_usage_record(
id INTEGER PRIMARY KEY AUTOINCREMENT,
occurred_at INTEGER NOT NULL,
usage_date TEXT NOT NULL,
bot_id INTEGER,
user_id INTEGER NOT NULL,
user_nickname TEXT NOT NULL DEFAULT '',
group_id INTEGER,
group_name TEXT,
endpoint_label TEXT,
model_alias TEXT,
provider TEXT,
model TEXT,
usage_kind TEXT NOT NULL DEFAULT 'chat',
unit TEXT NOT NULL DEFAULT 'tokens',
input_units INTEGER NOT NULL DEFAULT 0,
output_units INTEGER NOT NULL DEFAULT 0,
total_units INTEGER NOT NULL DEFAULT 0,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cached_tokens INTEGER NOT NULL DEFAULT 0,
call_count INTEGER NOT NULL DEFAULT 1,
detailed INTEGER NOT NULL DEFAULT 1,
legacy_key TEXT UNIQUE
)
""".trimIndent()
)
ensureColumn(connection, "token_usage_record", "model_alias", "TEXT")
ensureColumn(connection, "token_usage_record", "usage_kind", "TEXT NOT NULL DEFAULT 'chat'")
ensureColumn(connection, "token_usage_record", "unit", "TEXT NOT NULL DEFAULT 'tokens'")
ensureColumn(connection, "token_usage_record", "input_units", "INTEGER NOT NULL DEFAULT 0")
ensureColumn(connection, "token_usage_record", "output_units", "INTEGER NOT NULL DEFAULT 0")
ensureColumn(connection, "token_usage_record", "total_units", "INTEGER NOT NULL DEFAULT 0")
statement.executeUpdate(
"""
UPDATE token_usage_record
SET input_units = prompt_tokens,
output_units = completion_tokens,
total_units = total_tokens
WHERE unit = 'tokens'
AND input_units = 0 AND output_units = 0 AND total_units = 0
""".trimIndent()
)
statement.executeUpdate(
"UPDATE token_usage_record SET usage_kind = 'chat' WHERE usage_kind = 'tokens'"
)
statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_token_usage_date ON token_usage_record(usage_date)")
statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_token_usage_user_date ON token_usage_record(user_id, usage_date)")
statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_token_usage_group_date ON token_usage_record(group_id, usage_date)")
statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_token_usage_model_date ON token_usage_record(model, usage_date)")
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_token_usage_kind_date ON token_usage_record(usage_kind, unit, usage_date)"
)
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS token_usage_meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""".trimIndent()
)
}
connection.prepareStatement(
"INSERT INTO token_usage_meta(key, value) VALUES ('schema_version', ?) " +
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
).use { statement ->
statement.setString(1, SCHEMA_VERSION.toString())
statement.executeUpdate()
}
connection.commit()
} catch (cause: Throwable) {
connection.rollback()
throw cause
} finally {
connection.autoCommit = oldAutoCommit
}
}
private fun ensureColumn(
connection: Connection,
table: String,
column: String,
definition: String,
) {
val exists = connection.createStatement().use { statement ->
statement.executeQuery("PRAGMA table_info($table)").use { results ->
generateSequence { if (results.next()) results.getString("name") else null }
.any { it == column }
}
}
if (!exists) {
connection.createStatement().use { statement ->
statement.executeUpdate("ALTER TABLE $table ADD COLUMN $column $definition")
}
}
}
private fun openConnection(databaseFile: File): Connection =
DriverManager.getConnection("jdbc:sqlite:${databaseFile.absolutePath}")
private fun openReadConnection(databaseFile: File): Connection {
val config = SQLiteConfig().apply {
setReadOnly(true)
setBusyTimeout(BUSY_TIMEOUT_MS)
}
return DriverManager.getConnection("jdbc:sqlite:${databaseFile.absolutePath}", config.toProperties()).also {
it.createStatement().use { statement -> statement.execute("PRAGMA query_only=ON") }
}
}
private fun configureWriteConnection(connection: Connection) {
connection.createStatement().use { statement ->
statement.execute("PRAGMA journal_mode=WAL")
statement.execute("PRAGMA synchronous=NORMAL")
statement.execute("PRAGMA busy_timeout=$BUSY_TIMEOUT_MS")
statement.execute("PRAGMA wal_autocheckpoint=1000")
}
}
private fun ResultSet.toTokenUsageRecord(): TokenUsageRecord = TokenUsageRecord(
id = getLong("id"),
timestamp = getLong("occurred_at"),
date = getString("usage_date"),
botId = getLong("bot_id").takeUnless { wasNull() },
userId = getLong("user_id"),
userNickname = getString("user_nickname").orEmpty(),
groupId = getLong("group_id").takeUnless { wasNull() },
groupName = getString("group_name"),
endpointLabel = getString("endpoint_label"),
modelAlias = getString("model_alias"),
provider = getString("provider"),
model = getString("model"),
usageKind = getString("usage_kind") ?: "chat",
unit = getString("unit") ?: "tokens",
inputUnits = getLong("input_units"),
outputUnits = getLong("output_units"),
totalUnits = getLong("total_units"),
promptTokens = getLong("prompt_tokens"),
completionTokens = getLong("completion_tokens"),
totalTokens = getLong("total_tokens"),
cachedTokens = getLong("cached_tokens"),
callCount = getInt("call_count"),
detailed = getInt("detailed") != 0,
)
private fun warn(message: String, cause: Throwable?) {
warningLogger?.invoke(message, cause)
}
}