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:
WaterRain 2026-07-06 23:21:43 +00:00
parent 8469f01354
commit 14083f4be1
No known key found for this signature in database
8 changed files with 514 additions and 92 deletions

198
PLAN.md Normal file
View 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` 并删除此计划文件。

View File

@ -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)

View File

@ -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
/** 确保旧数据已迁移到 Room(仅执行一次) */
private suspend fun ensureMigrated() {
if (migrated) return
migrationMutex.withLock {
if (migrated) return
migrateDataStoreToRoom(context, dataStore, dao, json)
migrated = true
}
}
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())
suspend fun getAll(): List<UsageRecord> {
ensureMigrated()
return dao.getAll().map { it.toDomain() }
}
/** 写入后清空内存缓存 */
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())
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"
)

View 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?
}

View File

@ -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()
}

View File

@ -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
)

View File

@ -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 ----

View File

@ -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" }