import Foundation import SQLite3 enum SQLiteUsageCacheError: Error { case openFailed(String) case prepareFailed(String) case executeFailed(String) case bindFailed(String) case stepFailed(String) } final class SQLiteUsageCache { private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) private let database: OpaquePointer? private var calendar: Calendar = { var calendar = Calendar.current calendar.firstWeekday = 2 return calendar }() init(url: URL) throws { try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) var database: OpaquePointer? let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX guard sqlite3_open_v2(url.path, &database, flags, nil) == SQLITE_OK else { let message = database.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "无法打开 SQLite" sqlite3_close(database) throw SQLiteUsageCacheError.openFailed(message) } self.database = database try configure() try migrate() } deinit { sqlite3_close(database) } func isFileCurrent(path: String, size: Int64, modifiedAt: TimeInterval) throws -> Bool { let statement = try prepare("SELECT size, modified_at, scanned_offset FROM files WHERE path = ? LIMIT 1") defer { sqlite3_finalize(statement) } try bind(path, to: statement, at: 1) guard sqlite3_step(statement) == SQLITE_ROW else { return false } let cachedSize = sqlite3_column_int64(statement, 0) let cachedModifiedAt = sqlite3_column_double(statement, 1) let scannedOffset = sqlite3_column_int64(statement, 2) return cachedSize == size && scannedOffset == size && abs(cachedModifiedAt - modifiedAt) < 0.001 } func fileState(path: String) throws -> CachedFileState? { let statement = try prepare(""" SELECT size, modified_at, scanned_offset, record_count FROM files WHERE path = ? LIMIT 1 """) defer { sqlite3_finalize(statement) } try bind(path, to: statement, at: 1) guard sqlite3_step(statement) == SQLITE_ROW else { return nil } return CachedFileState( size: sqlite3_column_int64(statement, 0), modifiedAt: sqlite3_column_double(statement, 1), scannedOffset: sqlite3_column_int64(statement, 2), recordCount: Int(sqlite3_column_int64(statement, 3)) ) } func replaceFileRecords( path: String, size: Int64, modifiedAt: TimeInterval, scan: (_ insert: (UsageRecord) throws -> Void) throws -> Int64 ) throws { try execute("BEGIN IMMEDIATE TRANSACTION") do { try deleteRecords(path: path) let insertStatement = try prepare(""" INSERT INTO records ( id, provider, source_path, timestamp, session_id, model, input_tokens, output_tokens, cached_tokens, reasoning_tokens, tool_tokens, total_tokens, day_start, hour_start, month_start ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """) defer { sqlite3_finalize(insertStatement) } var recordCount = 0 let scannedOffset = try scan { record in recordCount += 1 try self.insert(record, id: "\(path)#\(recordCount)", statement: insertStatement) } try upsertFile( path: path, size: size, modifiedAt: modifiedAt, scannedOffset: scannedOffset, recordCount: recordCount ) try execute("COMMIT") } catch { try? execute("ROLLBACK") throw error } } func appendFileRecords( path: String, size: Int64, modifiedAt: TimeInterval, startingRecordCount: Int, scan: (_ insert: (UsageRecord) throws -> Void) throws -> Int64 ) throws { try execute("BEGIN IMMEDIATE TRANSACTION") do { let insertStatement = try prepare(""" INSERT INTO records ( id, provider, source_path, timestamp, session_id, model, input_tokens, output_tokens, cached_tokens, reasoning_tokens, tool_tokens, total_tokens, day_start, hour_start, month_start ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """) defer { sqlite3_finalize(insertStatement) } var appendedCount = 0 let scannedOffset = try scan { record in appendedCount += 1 let nextRecordIndex = startingRecordCount + appendedCount try self.insert(record, id: "\(path)#\(nextRecordIndex)", statement: insertStatement) } try upsertFile( path: path, size: size, modifiedAt: modifiedAt, scannedOffset: scannedOffset, recordCount: startingRecordCount + appendedCount ) try execute("COMMIT") } catch { try? execute("ROLLBACK") throw error } } func pruneMissingFiles(currentPaths: Set) throws { let statement = try prepare("SELECT path FROM files") defer { sqlite3_finalize(statement) } var cachedPaths: [String] = [] while sqlite3_step(statement) == SQLITE_ROW { cachedPaths.append(columnText(statement, at: 0)) } for path in cachedPaths where !currentPaths.contains(path) { try deleteRecords(path: path) let deleteFile = try prepare("DELETE FROM files WHERE path = ?") defer { sqlite3_finalize(deleteFile) } try bind(path, to: deleteFile, at: 1) try stepDone(deleteFile) } } func makeSummary(now: Date = Date(), calendar: Calendar = Calendar.current) throws -> UsageSummary { self.calendar = calendarWithMondayStart(calendar) let providerTotals = try providerBuckets() let todayProviderTotals = try providerBuckets( whereClause: "timestamp >= ? AND timestamp < ?", bindings: dayBounds(for: now).bindings ) let dailyDetails = try dailyUsageDetails() let dailyTrend = dailyDetails .sorted { $0.date < $1.date } .suffix(30) .map { detail in DailyPoint(date: detail.date, values: detail.providerTotals) } let trendPointsByMode = try Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in (mode, try trendPoints(mode: mode, now: now)) }) let toolRows = Provider.allCases.compactMap { provider -> ToolRow? in guard let bucket = providerTotals[provider], bucket.totalTokens > 0 else { return nil } return ToolRow( provider: provider, inputTokens: bucket.inputTokens, outputTokens: bucket.outputTokens, cachedTokens: bucket.cachedTokens, totalTokens: bucket.totalTokens, trendTokens: dailyTrend.map { $0.values[provider, default: 0] } ) } .sorted { $0.totalTokens > $1.totalTokens } let todayTokens = todayProviderTotals.values.reduce(0) { $0 + $1.totalTokens } let monthTokens = try tokenSum(whereClause: "timestamp >= ? AND timestamp < ?", bindings: monthBounds(for: now).bindings) let totalTokens = providerTotals.values.reduce(0) { $0 + $1.totalTokens } let cachedTokens = providerTotals.values.reduce(0) { $0 + $1.cachedTokens } let cacheHitRate = totalTokens > 0 ? Int((Double(cachedTokens) / Double(totalTokens) * 100).rounded()) : 0 let cards = SummaryCards( todayTokens: todayTokens, monthTokens: monthTokens, totalTokens: totalTokens, cacheHitRate: cacheHitRate, sessionCount: try sessionCount() ) return UsageSummary( generatedAt: now, records: [], cards: cards, providerTotals: providerTotals, todayProviderTotals: todayProviderTotals, dailyTrend: Array(dailyTrend), trendPointsByMode: trendPointsByMode, dailyDetails: dailyDetails.sorted { $0.date > $1.date }, toolRows: toolRows, insights: UsageSummary.makeInsights( providerTotals: providerTotals, todayTokens: todayTokens, cacheHitRate: cacheHitRate ) ) } private func configure() throws { try execute("PRAGMA journal_mode = WAL") try execute("PRAGMA synchronous = NORMAL") try execute("PRAGMA temp_store = MEMORY") try execute("PRAGMA busy_timeout = 3000") } private func migrate() throws { if try recordsTableNeedsRebuild() { try execute("DROP TABLE IF EXISTS records") try execute("DROP TABLE IF EXISTS files") } try execute(""" CREATE TABLE IF NOT EXISTS files ( path TEXT PRIMARY KEY, size INTEGER NOT NULL, modified_at REAL NOT NULL, scanned_offset INTEGER NOT NULL DEFAULT 0, record_count INTEGER NOT NULL DEFAULT 0, scanned_at REAL NOT NULL ) """) try addColumnIfMissing( table: "files", column: "scanned_offset", definition: "scanned_offset INTEGER NOT NULL DEFAULT 0" ) try addColumnIfMissing( table: "files", column: "record_count", definition: "record_count INTEGER NOT NULL DEFAULT 0" ) try execute(""" CREATE TABLE IF NOT EXISTS records ( id TEXT PRIMARY KEY, provider TEXT NOT NULL, source_path TEXT NOT NULL, timestamp REAL NOT NULL, session_id TEXT NOT NULL, model TEXT NOT NULL, input_tokens INTEGER NOT NULL, output_tokens INTEGER NOT NULL, cached_tokens INTEGER NOT NULL, reasoning_tokens INTEGER NOT NULL, tool_tokens INTEGER NOT NULL, total_tokens INTEGER NOT NULL, day_start REAL NOT NULL, hour_start REAL NOT NULL, month_start REAL NOT NULL ) """) try execute("CREATE INDEX IF NOT EXISTS records_provider_idx ON records(provider)") try execute("CREATE INDEX IF NOT EXISTS records_source_path_idx ON records(source_path)") try execute("CREATE INDEX IF NOT EXISTS records_timestamp_idx ON records(timestamp)") try execute("CREATE INDEX IF NOT EXISTS records_day_provider_idx ON records(day_start, provider)") try execute("CREATE INDEX IF NOT EXISTS records_hour_provider_idx ON records(hour_start, provider)") try execute("CREATE INDEX IF NOT EXISTS records_month_provider_idx ON records(month_start, provider)") } private func deleteRecords(path: String) throws { let statement = try prepare("DELETE FROM records WHERE source_path = ?") defer { sqlite3_finalize(statement) } try bind(path, to: statement, at: 1) try stepDone(statement) } private func upsertFile( path: String, size: Int64, modifiedAt: TimeInterval, scannedOffset: Int64, recordCount: Int ) throws { let statement = try prepare(""" INSERT INTO files (path, size, modified_at, scanned_offset, record_count, scanned_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(path) DO UPDATE SET size = excluded.size, modified_at = excluded.modified_at, scanned_offset = excluded.scanned_offset, record_count = excluded.record_count, scanned_at = excluded.scanned_at """) defer { sqlite3_finalize(statement) } try bind(path, to: statement, at: 1) sqlite3_bind_int64(statement, 2, size) sqlite3_bind_double(statement, 3, modifiedAt) sqlite3_bind_int64(statement, 4, scannedOffset) sqlite3_bind_int64(statement, 5, Int64(recordCount)) sqlite3_bind_double(statement, 6, Date().timeIntervalSince1970) try stepDone(statement) } private func insert(_ record: UsageRecord, id: String, statement: OpaquePointer?) throws { sqlite3_reset(statement) sqlite3_clear_bindings(statement) try bind(id, to: statement, at: 1) try bind(record.provider.rawValue, to: statement, at: 2) try bind(record.sourcePath, to: statement, at: 3) sqlite3_bind_double(statement, 4, record.timestamp.timeIntervalSince1970) try bind(record.sessionID, to: statement, at: 5) try bind(record.model, to: statement, at: 6) sqlite3_bind_int64(statement, 7, Int64(record.inputTokens)) sqlite3_bind_int64(statement, 8, Int64(record.outputTokens)) sqlite3_bind_int64(statement, 9, Int64(record.cachedTokens)) sqlite3_bind_int64(statement, 10, Int64(record.reasoningTokens)) sqlite3_bind_int64(statement, 11, Int64(record.toolTokens)) sqlite3_bind_int64(statement, 12, Int64(record.totalTokens)) sqlite3_bind_double(statement, 13, calendar.startOfDay(for: record.timestamp).timeIntervalSince1970) sqlite3_bind_double(statement, 14, hourStart(for: record.timestamp).timeIntervalSince1970) sqlite3_bind_double(statement, 15, monthStart(for: record.timestamp).timeIntervalSince1970) try stepDone(statement) } private func providerBuckets( whereClause: String? = nil, bindings: [TimeInterval] = [] ) throws -> [Provider: TokenBucket] { var buckets = Dictionary(uniqueKeysWithValues: Provider.allCases.map { ($0, TokenBucket(provider: $0)) }) var sql = """ SELECT provider, SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens), SUM(reasoning_tokens), SUM(tool_tokens), SUM(total_tokens), COUNT(*) FROM records """ if let whereClause { sql += " WHERE \(whereClause)" } sql += " GROUP BY provider" let statement = try prepare(sql) defer { sqlite3_finalize(statement) } try bindIntervals(bindings, to: statement) while sqlite3_step(statement) == SQLITE_ROW { guard let provider = Provider(rawValue: columnText(statement, at: 0)) else { continue } var bucket = TokenBucket(provider: provider) bucket.inputTokens = Int(sqlite3_column_int64(statement, 1)) bucket.outputTokens = Int(sqlite3_column_int64(statement, 2)) bucket.cachedTokens = Int(sqlite3_column_int64(statement, 3)) bucket.reasoningTokens = Int(sqlite3_column_int64(statement, 4)) bucket.toolTokens = Int(sqlite3_column_int64(statement, 5)) bucket.totalTokens = Int(sqlite3_column_int64(statement, 6)) bucket.count = Int(sqlite3_column_int64(statement, 7)) buckets[provider] = bucket } return buckets } private func dailyUsageDetails() throws -> [DailyUsageDetail] { let statement = try prepare(""" SELECT day_start, provider, SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens), SUM(total_tokens) FROM records GROUP BY day_start, provider ORDER BY day_start DESC """) defer { sqlite3_finalize(statement) } var details: [Date: DailyUsageDetail] = [:] while sqlite3_step(statement) == SQLITE_ROW { guard let provider = Provider(rawValue: columnText(statement, at: 1)) else { continue } let date = Date(timeIntervalSince1970: sqlite3_column_double(statement, 0)) if details[date] == nil { details[date] = DailyUsageDetail(date: date) } details[date]?.add( provider: provider, inputTokens: Int(sqlite3_column_int64(statement, 2)), outputTokens: Int(sqlite3_column_int64(statement, 3)), cachedTokens: Int(sqlite3_column_int64(statement, 4)), totalTokens: Int(sqlite3_column_int64(statement, 5)) ) } return details.values.sorted { $0.date > $1.date } } private func trendPoints(mode: TrendMode, now: Date) throws -> [DailyPoint] { let reference = mode.periodStart(for: now, calendar: calendar) let start = mode.startDate(reference: reference, calendar: calendar) let column = mode.periodColumn let statement = try prepare(""" SELECT \(column), provider, SUM(total_tokens) FROM records WHERE \(column) >= ? AND \(column) <= ? GROUP BY \(column), provider """) defer { sqlite3_finalize(statement) } sqlite3_bind_double(statement, 1, start.timeIntervalSince1970) sqlite3_bind_double(statement, 2, reference.timeIntervalSince1970) var buckets: [Date: [Provider: Int]] = [:] while sqlite3_step(statement) == SQLITE_ROW { guard let provider = Provider(rawValue: columnText(statement, at: 1)) else { continue } let date = Date(timeIntervalSince1970: sqlite3_column_double(statement, 0)) buckets[date, default: Provider.zeroTokenMap][provider, default: 0] = Int(sqlite3_column_int64(statement, 2)) } var points: [DailyPoint] = [] var cursor = start while cursor <= reference { points.append(DailyPoint(date: cursor, values: buckets[cursor, default: Provider.zeroTokenMap])) guard let next = calendar.date(byAdding: mode.calendarComponent, value: 1, to: cursor) else { break } cursor = next } return points } private func tokenSum(whereClause: String, bindings: [TimeInterval]) throws -> Int { let statement = try prepare("SELECT COALESCE(SUM(total_tokens), 0) FROM records WHERE \(whereClause)") defer { sqlite3_finalize(statement) } try bindIntervals(bindings, to: statement) guard sqlite3_step(statement) == SQLITE_ROW else { return 0 } return Int(sqlite3_column_int64(statement, 0)) } private func sessionCount() throws -> Int { let statement = try prepare(""" SELECT COUNT(DISTINCT CASE WHEN session_id = '' THEN source_path ELSE session_id END) FROM records """) defer { sqlite3_finalize(statement) } guard sqlite3_step(statement) == SQLITE_ROW else { return 0 } return Int(sqlite3_column_int64(statement, 0)) } private func recordsTableNeedsRebuild() throws -> Bool { let columns = try tableColumns("records") return !columns.isEmpty && !columns.contains("day_start") } private func addColumnIfMissing(table: String, column: String, definition: String) throws { let columns = try tableColumns(table) guard !columns.isEmpty, !columns.contains(column) else { return } try execute("ALTER TABLE \(table) ADD COLUMN \(definition)") } private func tableColumns(_ table: String) throws -> Set { let statement = try prepare("PRAGMA table_info(\(table))") defer { sqlite3_finalize(statement) } var columns = Set() while sqlite3_step(statement) == SQLITE_ROW { columns.insert(columnText(statement, at: 1)) } return columns } private func bindIntervals(_ values: [TimeInterval], to statement: OpaquePointer?) throws { for (index, value) in values.enumerated() { sqlite3_bind_double(statement, Int32(index + 1), value) } } private func dayBounds(for date: Date) -> QueryBounds { let start = calendar.startOfDay(for: date) let end = calendar.date(byAdding: .day, value: 1, to: start) ?? date return QueryBounds(start: start, end: end) } private func monthBounds(for date: Date) -> QueryBounds { let start = monthStart(for: date) let end = calendar.date(byAdding: .month, value: 1, to: start) ?? date return QueryBounds(start: start, end: end) } private func hourStart(for date: Date) -> Date { let components = calendar.dateComponents([.year, .month, .day, .hour], from: date) return calendar.date(from: components) ?? calendar.startOfDay(for: date) } private func monthStart(for date: Date) -> Date { let components = calendar.dateComponents([.year, .month], from: date) return calendar.date(from: components) ?? calendar.startOfDay(for: date) } private func calendarWithMondayStart(_ calendar: Calendar) -> Calendar { var copy = calendar copy.firstWeekday = 2 return copy } private func prepare(_ sql: String) throws -> OpaquePointer? { var statement: OpaquePointer? guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { throw SQLiteUsageCacheError.prepareFailed(errorMessage) } return statement } private func execute(_ sql: String) throws { guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { throw SQLiteUsageCacheError.executeFailed(errorMessage) } } private func bind(_ text: String, to statement: OpaquePointer?, at index: Int32) throws { let result = text.withCString { sqlite3_bind_text(statement, index, $0, -1, Self.transient) } guard result == SQLITE_OK else { throw SQLiteUsageCacheError.bindFailed(errorMessage) } } private func stepDone(_ statement: OpaquePointer?) throws { guard sqlite3_step(statement) == SQLITE_DONE else { throw SQLiteUsageCacheError.stepFailed(errorMessage) } } private func columnText(_ statement: OpaquePointer?, at index: Int32) -> String { guard let text = sqlite3_column_text(statement, index) else { return "" } return String(cString: UnsafeRawPointer(text).assumingMemoryBound(to: CChar.self)) } private var errorMessage: String { database.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "SQLite 操作失败" } } struct CachedFileState { let size: Int64 let modifiedAt: TimeInterval let scannedOffset: Int64 let recordCount: Int } private struct QueryBounds { let start: Date let end: Date var bindings: [TimeInterval] { [start.timeIntervalSince1970, end.timeIntervalSince1970] } }