refactor: migrate UsageCache from DataStore JSON to Room
- New: UsageRecordEntity (Room Entity, indexed on workspaceId+timeCreated) - New: UsageDao (CRUD + time-range queries) - New: UsageDatabase (Room Database, version 1) - New: migrateDataStoreToRoom() — one-time migration from old DataStore JSON - Rewrite: UsageCache internal impl from JSON serialize/deserialize to Room DAO - UsageCache public API unchanged (getAll/insertAll/getRecords/count/etc.) - Old DataStore key cleared after successful migration - SharedPreferences flag prevents re-migration - Room 2.7.1 + KSP compatible - libs.versions.toml + build.gradle.kts updated
This commit is contained in:
parent
8469f01354
commit
14083f4be1
198
PLAN.md
Normal file
198
PLAN.md
Normal file
@ -0,0 +1,198 @@
|
||||
# RainyToken 改进计划
|
||||
|
||||
> 临时计划文件,执行完毕后删除。
|
||||
|
||||
---
|
||||
|
||||
## 1. 迁移 UsageCache 到 Room
|
||||
|
||||
**现状问题**:
|
||||
- ~3700 条记录序列化为一个 JSON 字符串存入 DataStore 单 key
|
||||
- 每次 `insertAll` → `loadAll()` 全量读取 → 追加 → `persist()` 全量序列化写回
|
||||
- APP 冷启动时内存缓存丢失,需反序列化整个 JSON 字符串
|
||||
- 数据只增不减,半年后可能上万条,写入和启动读取会线性恶化
|
||||
- `getRecords(workspaceId, fromTs, toTs)` 是全量遍历后过滤,Room 用索引查询是 O(log n)
|
||||
|
||||
**目标**:
|
||||
- 用 Room 替换 DataStore JSON 方案
|
||||
- `UsageRecord` 作为 Entity,`workspaceId` + `timeCreated` 建索引
|
||||
- `UsageCache` 接口不变(`getAll` / `insertAll` / `getRecords` / `getStatsByModel` / `getStatsByDay` / `getOverview` / `deleteByWorkspaceId` / `count` / `getDistinctModels` / `getLatest` / `getAllIds`),内部实现从 JSON 序列化改为 Room DAO 查询
|
||||
- 保留内存缓存层(`@Volatile cachedAll`),但改为按需查询而非全量加载
|
||||
- 数据迁移:首次启动时检查旧 DataStore 数据,一次性导入 Room,然后清理旧 key
|
||||
|
||||
**涉及文件**:
|
||||
- 新建:`data/local/UsageDatabase.kt`(Room Database + DAO)
|
||||
- 新建:`data/local/UsageRecordEntity.kt`(Entity 定义,从现有 `UsageRecord` 迁移)
|
||||
- 修改:`data/local/UsageCache.kt`(内部实现改为 Room DAO 调用,接口不变)
|
||||
- 修改:`di/NetworkModule.kt`(@Provides RoomDatabase 实例)
|
||||
- 修改:`app/build.gradle.kts`(添加 Room 依赖)
|
||||
|
||||
**验证标准**:
|
||||
- APP 启动后 OCGO / CCGO 用量数据正常显示
|
||||
- 下拉刷新触发增量同步后新数据正确入库
|
||||
- CCGO 清除按钮能正确删除并重新全量同步
|
||||
- 图表、总览、原始数据页面正常渲染
|
||||
- 冷启动不再有全量 JSON 反序列化卡顿
|
||||
|
||||
---
|
||||
|
||||
## 2. 加解析单元测试
|
||||
|
||||
**现状问题**:
|
||||
- `OllamaRepository.parseUsage()` 和 `OpenCodeGoRepository.parseWindows()` 用正则解析 HTML
|
||||
- 对方改一次页面结构就失效,且失效表现是静默的(显示 0% 而非报错)
|
||||
- `CredentialEditViewModel.load()` 每次加新服务都要改回显分支,漏了用户看不到已存凭据
|
||||
|
||||
**目标**:
|
||||
- 纯 JVM 单元测试,不需要 Android 设备
|
||||
- 测试用例用之前抓到的真实 HTML(Ollama settings 页)作为 fixture
|
||||
|
||||
**涉及文件**:
|
||||
- 新建:`app/src/test/java/com/rainy/token/data/repository/OllamaRepositoryTest.kt`
|
||||
- 测试 plan 解析(Pro / Max / Free)
|
||||
- 测试 session/weekly 百分比解析(含小数 35.1%)
|
||||
- 测试 data-time 重置时间戳解析
|
||||
- 测试 data-model + data-requests 模型级数据解析
|
||||
- 测试无效 Cookie / 空页面 / HTML 结构变更场景
|
||||
- 新建:`app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt`
|
||||
- 测试 rollingUsage / weeklyUsage / monthlyUsage 解析
|
||||
- 测试嵌套对象的括号匹配
|
||||
- 测试 HTML 无目标数据时的 fallback
|
||||
|
||||
**验证标准**:
|
||||
- `./gradlew test` 全部通过
|
||||
- 测试覆盖正则解析的主要路径和边界情况
|
||||
|
||||
---
|
||||
|
||||
## 3. DashboardScreen 拆分
|
||||
|
||||
**现状问题**:
|
||||
- DashboardScreen.kt 1121 行,包含主框架 + 所有服务卡片 + 拖拽逻辑 + 工具函数
|
||||
- 加一个新服务要改这一个文件至少 3 处
|
||||
|
||||
**目标**:
|
||||
- 拆成 3 个文件:
|
||||
- `DashboardScreen.kt` — 主框架 + Scaffold + PullToRefresh(~200 行)
|
||||
- `ServiceBalanceCards.kt` — 各服务的 MainBalance + UsageWindows Composable
|
||||
- `DashboardDragState.kt` — 拖拽状态管理类 + 工具函数
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`ui/dashboard/DashboardScreen.kt`(大幅精简)
|
||||
- 新建:`ui/dashboard/ServiceBalanceCards.kt`
|
||||
- 新建:`ui/dashboard/DashboardDragState.kt`
|
||||
|
||||
**验证标准**:
|
||||
- 编译通过
|
||||
- Dashboard 功能不变(卡片显示、拖拽排序、下拉刷新、自适应布局)
|
||||
- 新增服务时只需改 `ServiceBalanceCards.kt` + `secondaryLine`
|
||||
|
||||
---
|
||||
|
||||
## 4. 重复函数提取
|
||||
|
||||
**现状问题**:
|
||||
- `formatAmount(Double)` — DashboardScreen.kt + ServiceDetailScreen.kt 各一份
|
||||
- `normalizeWindowLabel(String)` — DashboardScreen.kt + WidgetProvider 各一份
|
||||
- `formatResetInSec(Long)` / `formatReset(Long)` — DashboardScreen.kt + ServiceDetailScreen.kt + WidgetProvider 三份
|
||||
- `parseCookieString(String)` — CredentialEditViewModel.kt 独一份,但应该在公共位置
|
||||
|
||||
**目标**:
|
||||
- 抽取到 `ui/components/FormatUtils.kt`(UI 层共享)
|
||||
- `parseCookieString` 抽到 `data/repository/CookieUtils.kt`
|
||||
|
||||
**涉及文件**:
|
||||
- 新建:`ui/components/FormatUtils.kt`
|
||||
- 新建:`data/repository/CookieUtils.kt`
|
||||
- 修改:DashboardScreen.kt / ServiceDetailScreen.kt / OpenCodeGoWidgetProvider.kt / CredentialEditViewModel.kt(删除重复定义,改用 import)
|
||||
|
||||
**验证标准**:
|
||||
- 编译通过,无行为变化
|
||||
|
||||
---
|
||||
|
||||
## 5. Repository 加重试
|
||||
|
||||
**现状问题**:
|
||||
- 所有 Repository 的 `fetchBalance()` 只做一次请求,失败就返回 `Result.failure`
|
||||
- 网络抖动时用户需手动下拉刷新
|
||||
|
||||
**目标**:
|
||||
- 对 `RepositoryError.Network` 和 `RepositoryError.ServerError(5xx)` 加一次指数退避重试(最多 2 次)
|
||||
- 401/403/429 不重试
|
||||
- 可以做成 OkHttp Interceptor 或在 Repository 层包一层 `retryOnce`
|
||||
|
||||
**涉及文件**:
|
||||
- 新建或修改:`data/repository/RetryHelper.kt`(通用重试包装)
|
||||
- 修改:各 Repository 的 `fetchBalance()`(包一层 retry)
|
||||
|
||||
**验证标准**:
|
||||
- 编译通过
|
||||
- 网络抖动时自动重试一次而非直接失败
|
||||
- 401/403 仍然立即失败不重试
|
||||
|
||||
---
|
||||
|
||||
## 6. CredentialEditViewModel 模板化
|
||||
|
||||
**现状问题**:
|
||||
- `saveOpenCodeGoSession()` / `testAndSaveOpenCodeGo()` / `saveOllamaCredential()` / `testAndSaveOllama()` / `saveCommandCodeGoCredential()` / `testAndSaveCommandCodeGo()` 等方法成对出现,结构几乎一样
|
||||
|
||||
**目标**:
|
||||
- 抽取通用的 `saveAndTestCredential(service, saveBlock, testBlock)` 模板方法
|
||||
- 各服务只传不同的 save + test lambda
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`ui/settings/CredentialEditViewModel.kt`
|
||||
|
||||
**验证标准**:
|
||||
- 编译通过
|
||||
- 各服务的保存/测试功能不变
|
||||
- 新增服务时只需写 save + test 两个 lambda,不再写完整的成对方法
|
||||
|
||||
---
|
||||
|
||||
## 7. 删除 Jsoup 依赖
|
||||
|
||||
**现状问题**:
|
||||
- `build.gradle.kts` 引入了 `org.jsoup:jsoup`,代码里 0 处 import
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`app/build.gradle.kts`(删除 `implementation(libs.jsoup)`)
|
||||
|
||||
**验证标准**:
|
||||
- 编译通过
|
||||
|
||||
---
|
||||
|
||||
## 8. ChartSettingsStore 改 Flow
|
||||
|
||||
**现状问题**:
|
||||
- `ChartSettingsStore.getUseUtc8()` 用 `runBlocking` 同步读 DataStore,在 UI 线程调用
|
||||
|
||||
**目标**:
|
||||
- 改成 `StateFlow` 暴露,Composable 层 `collectAsState`
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`data/local/ChartSettingsStore.kt`
|
||||
- 修改:调用 `getUseUtc8()` 的 Composable 层
|
||||
|
||||
**验证标准**:
|
||||
- 编译通过
|
||||
- 图表页 UTC 偏好切换正常
|
||||
- 退出再进入能恢复上次偏好
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 迁移 Room(基础设施,后续都依赖它)
|
||||
2. 加解析单元测试(在改其他代码前先有回归保护)
|
||||
3. DashboardScreen 拆分
|
||||
4. 重复函数提取
|
||||
5. Repository 加重试
|
||||
6. CredentialEditViewModel 模板化
|
||||
7. 删除 Jsoup 依赖
|
||||
8. ChartSettingsStore 改 Flow
|
||||
|
||||
每步完成后 `./gradlew assembleDebug` 验证编译,全部完成后执行 `git commit` 并删除此计划文件。
|
||||
@ -109,6 +109,11 @@ dependencies {
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// Test
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@ -3,91 +3,81 @@ package com.rainy.token.data.local
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* 用量记录本地缓存(DataStore,非 Room)。
|
||||
* 用量记录本地缓存(Room 实现)。
|
||||
*
|
||||
* 所有记录序列化为 JSON 列表存入 DataStore。
|
||||
* 约 3700 条记录,每条约 300 字节 → ~1.1MB,DataStore 可承受。
|
||||
* 接口与旧 DataStore 版本完全一致,内部改为 Room DAO 查询。
|
||||
* 首次访问时自动从旧 DataStore JSON 迁移数据到 Room。
|
||||
*/
|
||||
class UsageCache(
|
||||
private val context: Context,
|
||||
private val dao: UsageDao,
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
) {
|
||||
private val cacheKey = stringPreferencesKey("usage_cache_v1")
|
||||
private val migrationMutex = Mutex()
|
||||
@Volatile private var migrated = false
|
||||
|
||||
// ── 内存缓存:避免每次都反序列化整个 JSON ──
|
||||
@Volatile private var cachedAll: List<UsageRecord>? = null
|
||||
@Volatile private var cachedIds: Set<String>? = null
|
||||
|
||||
/** 读取全量(优先内存缓存) */
|
||||
private suspend fun loadAll(): List<UsageRecord> {
|
||||
cachedAll?.let { return it }
|
||||
val records = readFromStore()
|
||||
cachedAll = records
|
||||
cachedIds = records.map { it.id }.toSet()
|
||||
return records
|
||||
}
|
||||
|
||||
private suspend fun readFromStore(): List<UsageRecord> {
|
||||
val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyList()
|
||||
return runCatching {
|
||||
json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw)
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
/** 写入后清空内存缓存 */
|
||||
private fun invalidateCache() {
|
||||
cachedAll = null
|
||||
cachedIds = null
|
||||
}
|
||||
|
||||
suspend fun getAll(): List<UsageRecord> = loadAll()
|
||||
|
||||
fun getAllFlow(): Flow<List<UsageRecord>> =
|
||||
dataStore.data.map { prefs ->
|
||||
val raw = prefs[cacheKey] ?: return@map emptyList()
|
||||
runCatching {
|
||||
json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw)
|
||||
}.getOrDefault(emptyList())
|
||||
/** 确保旧数据已迁移到 Room(仅执行一次) */
|
||||
private suspend fun ensureMigrated() {
|
||||
if (migrated) return
|
||||
migrationMutex.withLock {
|
||||
if (migrated) return
|
||||
migrateDataStoreToRoom(context, dataStore, dao, json)
|
||||
migrated = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAll(): List<UsageRecord> {
|
||||
ensureMigrated()
|
||||
return dao.getAll().map { it.toDomain() }
|
||||
}
|
||||
|
||||
fun getAllFlow(): Flow<List<UsageRecord>> = flow {
|
||||
ensureMigrated()
|
||||
emit(dao.getAll().map { it.toDomain() })
|
||||
}
|
||||
|
||||
suspend fun insertAll(newRecords: List<UsageRecord>) {
|
||||
val current = loadAll().toMutableList()
|
||||
val existingIds = cachedIds ?: current.map { it.id }.toSet()
|
||||
val toAdd = newRecords.filter { it.id !in existingIds }
|
||||
if (toAdd.isEmpty()) return
|
||||
current.addAll(toAdd)
|
||||
persist(current)
|
||||
ensureMigrated()
|
||||
if (newRecords.isEmpty()) return
|
||||
val entities = newRecords.map { it.toEntity() }
|
||||
dao.insertAll(entities)
|
||||
}
|
||||
|
||||
suspend fun getLatest(): UsageRecord? = loadAll().maxByOrNull { it.timeCreated }
|
||||
suspend fun getLatest(): UsageRecord? {
|
||||
ensureMigrated()
|
||||
return dao.getLatest()?.toDomain()
|
||||
}
|
||||
|
||||
suspend fun getAllIds(): Set<String> {
|
||||
cachedIds?.let { return it }
|
||||
return loadAll().map { it.id }.toSet()
|
||||
ensureMigrated()
|
||||
return dao.getAllIds().toSet()
|
||||
}
|
||||
|
||||
/** 按 workspaceId 删除所有记录。用于修复旧数据格式问题后重新全量同步。 */
|
||||
suspend fun deleteByWorkspaceId(workspaceId: String) {
|
||||
val current = loadAll().toMutableList()
|
||||
val removed = current.removeAll { it.workspaceId == workspaceId }
|
||||
if (removed) persist(current)
|
||||
ensureMigrated()
|
||||
dao.deleteByWorkspaceId(workspaceId)
|
||||
}
|
||||
|
||||
suspend fun count(): Int = loadAll().size
|
||||
suspend fun count(): Int {
|
||||
ensureMigrated()
|
||||
return dao.count()
|
||||
}
|
||||
|
||||
/** 按 workspaceId 统计记录数。 */
|
||||
suspend fun count(workspaceId: String): Int = loadAll().count { it.workspaceId == workspaceId }
|
||||
suspend fun count(workspaceId: String): Int {
|
||||
ensureMigrated()
|
||||
return dao.countByWorkspace(workspaceId)
|
||||
}
|
||||
|
||||
/** 获取过滤后的原始记录列表(供图表等聚合使用) */
|
||||
suspend fun getRecords(
|
||||
@ -95,24 +85,20 @@ class UsageCache(
|
||||
fromTs: Long? = null,
|
||||
toTs: Long? = null
|
||||
): List<UsageRecord> {
|
||||
return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
|
||||
ensureMigrated()
|
||||
val entities = when {
|
||||
fromTs != null && toTs != null -> dao.getByWorkspaceAndTime(workspaceId, fromTs, toTs)
|
||||
fromTs != null -> dao.getByWorkspaceFrom(workspaceId, fromTs)
|
||||
toTs != null -> dao.getByWorkspaceTo(workspaceId, toTs)
|
||||
else -> dao.getByWorkspace(workspaceId)
|
||||
}
|
||||
return entities.map { it.toDomain() }
|
||||
}
|
||||
|
||||
/** 获取所有不同模型名称 */
|
||||
suspend fun getDistinctModels(workspaceId: String): List<String> {
|
||||
return loadAll()
|
||||
.filter { it.workspaceId == workspaceId }
|
||||
.map { it.model }
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
|
||||
/** 按时间范围过滤记录 */
|
||||
private fun filterByTime(records: List<UsageRecord>, fromTs: Long?, toTs: Long?): List<UsageRecord> {
|
||||
var result = records
|
||||
if (fromTs != null) result = result.filter { it.timeCreated >= fromTs }
|
||||
if (toTs != null) result = result.filter { it.timeCreated <= toTs }
|
||||
return result
|
||||
ensureMigrated()
|
||||
return dao.getDistinctModels(workspaceId)
|
||||
}
|
||||
|
||||
suspend fun getStatsByModel(
|
||||
@ -120,14 +106,15 @@ class UsageCache(
|
||||
fromTs: Long? = null,
|
||||
toTs: Long? = null
|
||||
): List<ModelStats> {
|
||||
return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
|
||||
val records = getRecords(workspaceId, fromTs, toTs)
|
||||
return records
|
||||
.groupBy { it.model }
|
||||
.map { (model, records) ->
|
||||
.map { (model, recs) ->
|
||||
ModelStats(
|
||||
model = model,
|
||||
totalTokens = records.sumOf { it.totalTokens },
|
||||
totalCost = records.sumOf { it.cost },
|
||||
count = records.size
|
||||
totalTokens = recs.sumOf { it.totalTokens },
|
||||
totalCost = recs.sumOf { it.cost },
|
||||
count = recs.size
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.totalTokens }
|
||||
@ -139,14 +126,15 @@ class UsageCache(
|
||||
fromTs: Long? = null,
|
||||
toTs: Long? = null
|
||||
): List<DailyStats> {
|
||||
return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
|
||||
val records = getRecords(workspaceId, fromTs, toTs)
|
||||
return records
|
||||
.groupBy { it.timeCreated / 86_400_000L * 86_400_000L }
|
||||
.map { (dayTs, records) ->
|
||||
.map { (dayTs, recs) ->
|
||||
DailyStats(
|
||||
dayTs = dayTs,
|
||||
totalTokens = records.sumOf { it.totalTokens },
|
||||
totalCost = records.sumOf { it.cost },
|
||||
count = records.size
|
||||
totalTokens = recs.sumOf { it.totalTokens },
|
||||
totalCost = recs.sumOf { it.cost },
|
||||
count = recs.size
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.dayTs }
|
||||
@ -158,7 +146,7 @@ class UsageCache(
|
||||
fromTs: Long? = null,
|
||||
toTs: Long? = null
|
||||
): OverviewStats? {
|
||||
val records = filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
|
||||
val records = getRecords(workspaceId, fromTs, toTs)
|
||||
if (records.isEmpty()) return null
|
||||
return OverviewStats(
|
||||
totalTokens = records.sumOf { it.inputTokens + it.cacheReadTokens + it.outputTokens },
|
||||
@ -173,12 +161,6 @@ class UsageCache(
|
||||
cacheWrite1hTokens = records.sumOf { it.cacheWrite1hTokens }
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun persist(list: List<UsageRecord>) {
|
||||
val raw = json.encodeToString(ListSerializer(UsageRecord.serializer()), list)
|
||||
dataStore.edit { it[cacheKey] = raw }
|
||||
invalidateCache()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@ -234,7 +216,7 @@ data class OverviewStats(
|
||||
val cacheWriteTokens: Long get() = cacheWrite5mTokens + cacheWrite1hTokens
|
||||
}
|
||||
|
||||
/** DataStore 委托 */
|
||||
val Context.usageCacheDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
/** DataStore 委托 — 保留用于数据迁移,迁移完成后旧 key 会被清除 */
|
||||
val Context.usageCacheDataStore: DataStore<Preferences> by androidx.datastore.preferences.preferencesDataStore(
|
||||
name = "usage_cache"
|
||||
)
|
||||
52
app/src/main/java/com/rainy/token/data/local/UsageDao.kt
Normal file
52
app/src/main/java/com/rainy/token/data/local/UsageDao.kt
Normal file
@ -0,0 +1,52 @@
|
||||
package com.rainy.token.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface UsageDao {
|
||||
|
||||
@Query("SELECT * FROM usage_records")
|
||||
suspend fun getAll(): List<UsageRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId ORDER BY timeCreated DESC")
|
||||
suspend fun getByWorkspace(workspaceId: String): List<UsageRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated >= :fromTs AND timeCreated <= :toTs ORDER BY timeCreated DESC")
|
||||
suspend fun getByWorkspaceAndTime(workspaceId: String, fromTs: Long, toTs: Long): List<UsageRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated >= :fromTs ORDER BY timeCreated DESC")
|
||||
suspend fun getByWorkspaceFrom(workspaceId: String, fromTs: Long): List<UsageRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated <= :toTs ORDER BY timeCreated DESC")
|
||||
suspend fun getByWorkspaceTo(workspaceId: String, toTs: Long): List<UsageRecordEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertAll(records: List<UsageRecordEntity>): List<Long>
|
||||
|
||||
@Query("DELETE FROM usage_records WHERE workspaceId = :workspaceId")
|
||||
suspend fun deleteByWorkspaceId(workspaceId: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM usage_records")
|
||||
suspend fun count(): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM usage_records WHERE workspaceId = :workspaceId")
|
||||
suspend fun countByWorkspace(workspaceId: String): Int
|
||||
|
||||
@Query("SELECT DISTINCT model FROM usage_records WHERE workspaceId = :workspaceId ORDER BY model ASC")
|
||||
suspend fun getDistinctModels(workspaceId: String): List<String>
|
||||
|
||||
@Query("SELECT * FROM usage_records ORDER BY timeCreated DESC LIMIT 1")
|
||||
suspend fun getLatest(): UsageRecordEntity?
|
||||
|
||||
@Query("SELECT id FROM usage_records")
|
||||
suspend fun getAllIds(): List<String>
|
||||
|
||||
@Query("SELECT id FROM usage_records WHERE workspaceId = :workspaceId")
|
||||
suspend fun getIdsByWorkspace(workspaceId: String): List<String>
|
||||
|
||||
@Query("SELECT MAX(timeCreated) FROM usage_records WHERE workspaceId = :workspaceId")
|
||||
suspend fun getMaxTimeCreated(workspaceId: String): Long?
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
package com.rainy.token.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Database(
|
||||
entities = [UsageRecordEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class UsageDatabase : RoomDatabase() {
|
||||
abstract fun usageDao(): UsageDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: UsageDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): UsageDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
UsageDatabase::class.java,
|
||||
"usage_database"
|
||||
).build().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration from old DataStore JSON cache to Room.
|
||||
*
|
||||
* Called by [UsageCache] on first access. Reads the old JSON blob from DataStore,
|
||||
* deserializes it, inserts into Room, then clears the old key.
|
||||
*
|
||||
* A SharedPreferences flag prevents re-running if the old data was already consumed
|
||||
* (e.g. if the DataStore was cleared but the flag was set).
|
||||
*
|
||||
* @return true if migration ran and inserted records, false if no migration was needed.
|
||||
*/
|
||||
suspend fun migrateDataStoreToRoom(
|
||||
context: Context,
|
||||
dataStore: DataStore<Preferences>,
|
||||
dao: UsageDao,
|
||||
json: Json
|
||||
): Boolean {
|
||||
val prefs = context.getSharedPreferences("usage_cache_migration", Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean("migrated_to_room", false)) return false
|
||||
|
||||
val cacheKey = stringPreferencesKey("usage_cache_v1")
|
||||
val raw = dataStore.data.map { it[cacheKey] }.first() ?: run {
|
||||
// No old data — mark as migrated so we never check again
|
||||
prefs.edit().putBoolean("migrated_to_room", true).apply()
|
||||
return false
|
||||
}
|
||||
|
||||
val records = runCatching {
|
||||
json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw)
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
if (records.isNotEmpty()) {
|
||||
val entities = records.map { it.toEntity() }
|
||||
dao.insertAll(entities)
|
||||
}
|
||||
|
||||
// Mark migrated and clear old key to free space
|
||||
prefs.edit().putBoolean("migrated_to_room", true).apply()
|
||||
dataStore.edit { it.remove(cacheKey) }
|
||||
return records.isNotEmpty()
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package com.rainy.token.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Room Entity for usage records.
|
||||
*
|
||||
* Mirrors the fields of [UsageRecord] exactly.
|
||||
* Indexes on workspaceId + timeCreated for fast time-range queries.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "usage_records",
|
||||
indices = [
|
||||
Index(value = ["workspaceId", "timeCreated"]),
|
||||
Index(value = ["workspaceId", "model"]),
|
||||
Index(value = ["id"], unique = true)
|
||||
]
|
||||
)
|
||||
data class UsageRecordEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val workspaceId: String,
|
||||
val timeCreated: Long,
|
||||
val timeUpdated: Long,
|
||||
val model: String,
|
||||
val provider: String,
|
||||
val inputTokens: Long,
|
||||
val outputTokens: Long,
|
||||
val reasoningTokens: Long,
|
||||
val cacheReadTokens: Long,
|
||||
val cacheWrite5mTokens: Long = 0,
|
||||
val cacheWrite1hTokens: Long = 0,
|
||||
val cost: Long,
|
||||
val keyId: String,
|
||||
val sessionId: String,
|
||||
val enrichmentPlan: String = ""
|
||||
) {
|
||||
/** Convert Entity → domain model */
|
||||
fun toDomain(): UsageRecord = UsageRecord(
|
||||
id = id,
|
||||
workspaceId = workspaceId,
|
||||
timeCreated = timeCreated,
|
||||
timeUpdated = timeUpdated,
|
||||
model = model,
|
||||
provider = provider,
|
||||
inputTokens = inputTokens,
|
||||
outputTokens = outputTokens,
|
||||
reasoningTokens = reasoningTokens,
|
||||
cacheReadTokens = cacheReadTokens,
|
||||
cacheWrite5mTokens = cacheWrite5mTokens,
|
||||
cacheWrite1hTokens = cacheWrite1hTokens,
|
||||
cost = cost,
|
||||
keyId = keyId,
|
||||
sessionId = sessionId,
|
||||
enrichmentPlan = enrichmentPlan
|
||||
)
|
||||
|
||||
val totalTokens: Long get() = inputTokens + outputTokens + reasoningTokens
|
||||
}
|
||||
|
||||
/** Convert domain model → Entity */
|
||||
fun UsageRecord.toEntity(): UsageRecordEntity = UsageRecordEntity(
|
||||
id = id,
|
||||
workspaceId = workspaceId,
|
||||
timeCreated = timeCreated,
|
||||
timeUpdated = timeUpdated,
|
||||
model = model,
|
||||
provider = provider,
|
||||
inputTokens = inputTokens,
|
||||
outputTokens = outputTokens,
|
||||
reasoningTokens = reasoningTokens,
|
||||
cacheReadTokens = cacheReadTokens,
|
||||
cacheWrite5mTokens = cacheWrite5mTokens,
|
||||
cacheWrite1hTokens = cacheWrite1hTokens,
|
||||
cost = cost,
|
||||
keyId = keyId,
|
||||
sessionId = sessionId,
|
||||
enrichmentPlan = enrichmentPlan
|
||||
)
|
||||
@ -6,6 +6,8 @@ import com.rainy.token.data.cache.balanceCacheDataStore
|
||||
import com.rainy.token.data.remote.DeepSeekApi
|
||||
import com.rainy.token.data.local.SecureStorage
|
||||
import com.rainy.token.data.local.UsageCache
|
||||
import com.rainy.token.data.local.UsageDao
|
||||
import com.rainy.token.data.local.UsageDatabase
|
||||
import com.rainy.token.data.local.usageCacheDataStore
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.data.repository.DeepSeekRepository
|
||||
@ -78,12 +80,26 @@ object NetworkModule {
|
||||
|
||||
// ---- Room ----
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUsageDatabase(
|
||||
@ApplicationContext context: Context
|
||||
): UsageDatabase = UsageDatabase.getInstance(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUsageDao(
|
||||
database: UsageDatabase
|
||||
): UsageDao = database.usageDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUsageCache(
|
||||
@ApplicationContext context: Context,
|
||||
dao: UsageDao,
|
||||
@Named(DataStoreQualifiers.USAGE_CACHE) dataStore: DataStore<Preferences>,
|
||||
json: Json
|
||||
): UsageCache = UsageCache(dataStore, json)
|
||||
): UsageCache = UsageCache(context, dao, dataStore, json)
|
||||
|
||||
// ---- Repositories ----
|
||||
|
||||
|
||||
@ -33,6 +33,9 @@ workRuntimeKtx = "2.9.1"
|
||||
hilt = "2.59.2"
|
||||
hiltNavigationCompose = "1.2.0"
|
||||
|
||||
# Room
|
||||
room = "2.7.1"
|
||||
|
||||
# Test
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.2.1"
|
||||
@ -82,6 +85,11 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref
|
||||
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
|
||||
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||
|
||||
# Room
|
||||
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
# Test
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user