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)
|
ksp(libs.hilt.compiler)
|
||||||
implementation(libs.androidx.hilt.navigation.compose)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
|
|
||||||
|
// Room
|
||||||
|
implementation(libs.room.runtime)
|
||||||
|
implementation(libs.room.ktx)
|
||||||
|
ksp(libs.room.compiler)
|
||||||
|
|
||||||
// Test
|
// Test
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
@ -3,91 +3,81 @@ package com.rainy.token.data.local
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用量记录本地缓存(DataStore,非 Room)。
|
* 用量记录本地缓存(Room 实现)。
|
||||||
*
|
*
|
||||||
* 所有记录序列化为 JSON 列表存入 DataStore。
|
* 接口与旧 DataStore 版本完全一致,内部改为 Room DAO 查询。
|
||||||
* 约 3700 条记录,每条约 300 字节 → ~1.1MB,DataStore 可承受。
|
* 首次访问时自动从旧 DataStore JSON 迁移数据到 Room。
|
||||||
*/
|
*/
|
||||||
class UsageCache(
|
class UsageCache(
|
||||||
|
private val context: Context,
|
||||||
|
private val dao: UsageDao,
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
private val json: Json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
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 ──
|
/** 确保旧数据已迁移到 Room(仅执行一次) */
|
||||||
@Volatile private var cachedAll: List<UsageRecord>? = null
|
private suspend fun ensureMigrated() {
|
||||||
@Volatile private var cachedIds: Set<String>? = null
|
if (migrated) return
|
||||||
|
migrationMutex.withLock {
|
||||||
/** 读取全量(优先内存缓存) */
|
if (migrated) return
|
||||||
private suspend fun loadAll(): List<UsageRecord> {
|
migrateDataStoreToRoom(context, dataStore, dao, json)
|
||||||
cachedAll?.let { return it }
|
migrated = true
|
||||||
val records = readFromStore()
|
}
|
||||||
cachedAll = records
|
|
||||||
cachedIds = records.map { it.id }.toSet()
|
|
||||||
return records
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun readFromStore(): List<UsageRecord> {
|
suspend fun getAll(): List<UsageRecord> {
|
||||||
val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyList()
|
ensureMigrated()
|
||||||
return runCatching {
|
return dao.getAll().map { it.toDomain() }
|
||||||
json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw)
|
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写入后清空内存缓存 */
|
fun getAllFlow(): Flow<List<UsageRecord>> = flow {
|
||||||
private fun invalidateCache() {
|
ensureMigrated()
|
||||||
cachedAll = null
|
emit(dao.getAll().map { it.toDomain() })
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun insertAll(newRecords: List<UsageRecord>) {
|
suspend fun insertAll(newRecords: List<UsageRecord>) {
|
||||||
val current = loadAll().toMutableList()
|
ensureMigrated()
|
||||||
val existingIds = cachedIds ?: current.map { it.id }.toSet()
|
if (newRecords.isEmpty()) return
|
||||||
val toAdd = newRecords.filter { it.id !in existingIds }
|
val entities = newRecords.map { it.toEntity() }
|
||||||
if (toAdd.isEmpty()) return
|
dao.insertAll(entities)
|
||||||
current.addAll(toAdd)
|
|
||||||
persist(current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLatest(): UsageRecord? = loadAll().maxByOrNull { it.timeCreated }
|
suspend fun getLatest(): UsageRecord? {
|
||||||
|
ensureMigrated()
|
||||||
|
return dao.getLatest()?.toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getAllIds(): Set<String> {
|
suspend fun getAllIds(): Set<String> {
|
||||||
cachedIds?.let { return it }
|
ensureMigrated()
|
||||||
return loadAll().map { it.id }.toSet()
|
return dao.getAllIds().toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按 workspaceId 删除所有记录。用于修复旧数据格式问题后重新全量同步。 */
|
/** 按 workspaceId 删除所有记录。用于修复旧数据格式问题后重新全量同步。 */
|
||||||
suspend fun deleteByWorkspaceId(workspaceId: String) {
|
suspend fun deleteByWorkspaceId(workspaceId: String) {
|
||||||
val current = loadAll().toMutableList()
|
ensureMigrated()
|
||||||
val removed = current.removeAll { it.workspaceId == workspaceId }
|
dao.deleteByWorkspaceId(workspaceId)
|
||||||
if (removed) persist(current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun count(): Int = loadAll().size
|
suspend fun count(): Int {
|
||||||
|
ensureMigrated()
|
||||||
|
return dao.count()
|
||||||
|
}
|
||||||
|
|
||||||
/** 按 workspaceId 统计记录数。 */
|
/** 按 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(
|
suspend fun getRecords(
|
||||||
@ -95,24 +85,20 @@ class UsageCache(
|
|||||||
fromTs: Long? = null,
|
fromTs: Long? = null,
|
||||||
toTs: Long? = null
|
toTs: Long? = null
|
||||||
): List<UsageRecord> {
|
): 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> {
|
suspend fun getDistinctModels(workspaceId: String): List<String> {
|
||||||
return loadAll()
|
ensureMigrated()
|
||||||
.filter { it.workspaceId == workspaceId }
|
return dao.getDistinctModels(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getStatsByModel(
|
suspend fun getStatsByModel(
|
||||||
@ -120,14 +106,15 @@ class UsageCache(
|
|||||||
fromTs: Long? = null,
|
fromTs: Long? = null,
|
||||||
toTs: Long? = null
|
toTs: Long? = null
|
||||||
): List<ModelStats> {
|
): List<ModelStats> {
|
||||||
return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
|
val records = getRecords(workspaceId, fromTs, toTs)
|
||||||
|
return records
|
||||||
.groupBy { it.model }
|
.groupBy { it.model }
|
||||||
.map { (model, records) ->
|
.map { (model, recs) ->
|
||||||
ModelStats(
|
ModelStats(
|
||||||
model = model,
|
model = model,
|
||||||
totalTokens = records.sumOf { it.totalTokens },
|
totalTokens = recs.sumOf { it.totalTokens },
|
||||||
totalCost = records.sumOf { it.cost },
|
totalCost = recs.sumOf { it.cost },
|
||||||
count = records.size
|
count = recs.size
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sortedByDescending { it.totalTokens }
|
.sortedByDescending { it.totalTokens }
|
||||||
@ -139,14 +126,15 @@ class UsageCache(
|
|||||||
fromTs: Long? = null,
|
fromTs: Long? = null,
|
||||||
toTs: Long? = null
|
toTs: Long? = null
|
||||||
): List<DailyStats> {
|
): 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 }
|
.groupBy { it.timeCreated / 86_400_000L * 86_400_000L }
|
||||||
.map { (dayTs, records) ->
|
.map { (dayTs, recs) ->
|
||||||
DailyStats(
|
DailyStats(
|
||||||
dayTs = dayTs,
|
dayTs = dayTs,
|
||||||
totalTokens = records.sumOf { it.totalTokens },
|
totalTokens = recs.sumOf { it.totalTokens },
|
||||||
totalCost = records.sumOf { it.cost },
|
totalCost = recs.sumOf { it.cost },
|
||||||
count = records.size
|
count = recs.size
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sortedByDescending { it.dayTs }
|
.sortedByDescending { it.dayTs }
|
||||||
@ -158,7 +146,7 @@ class UsageCache(
|
|||||||
fromTs: Long? = null,
|
fromTs: Long? = null,
|
||||||
toTs: Long? = null
|
toTs: Long? = null
|
||||||
): OverviewStats? {
|
): OverviewStats? {
|
||||||
val records = filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
|
val records = getRecords(workspaceId, fromTs, toTs)
|
||||||
if (records.isEmpty()) return null
|
if (records.isEmpty()) return null
|
||||||
return OverviewStats(
|
return OverviewStats(
|
||||||
totalTokens = records.sumOf { it.inputTokens + it.cacheReadTokens + it.outputTokens },
|
totalTokens = records.sumOf { it.inputTokens + it.cacheReadTokens + it.outputTokens },
|
||||||
@ -173,12 +161,6 @@ class UsageCache(
|
|||||||
cacheWrite1hTokens = records.sumOf { it.cacheWrite1hTokens }
|
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
|
@Serializable
|
||||||
@ -234,7 +216,7 @@ data class OverviewStats(
|
|||||||
val cacheWriteTokens: Long get() = cacheWrite5mTokens + cacheWrite1hTokens
|
val cacheWriteTokens: Long get() = cacheWrite5mTokens + cacheWrite1hTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DataStore 委托 */
|
/** DataStore 委托 — 保留用于数据迁移,迁移完成后旧 key 会被清除 */
|
||||||
val Context.usageCacheDataStore: DataStore<Preferences> by preferencesDataStore(
|
val Context.usageCacheDataStore: DataStore<Preferences> by androidx.datastore.preferences.preferencesDataStore(
|
||||||
name = "usage_cache"
|
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.remote.DeepSeekApi
|
||||||
import com.rainy.token.data.local.SecureStorage
|
import com.rainy.token.data.local.SecureStorage
|
||||||
import com.rainy.token.data.local.UsageCache
|
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.local.usageCacheDataStore
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
import com.rainy.token.data.repository.CredentialRepository
|
||||||
import com.rainy.token.data.repository.DeepSeekRepository
|
import com.rainy.token.data.repository.DeepSeekRepository
|
||||||
@ -78,12 +80,26 @@ object NetworkModule {
|
|||||||
|
|
||||||
// ---- Room ----
|
// ---- Room ----
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideUsageDatabase(
|
||||||
|
@ApplicationContext context: Context
|
||||||
|
): UsageDatabase = UsageDatabase.getInstance(context)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideUsageDao(
|
||||||
|
database: UsageDatabase
|
||||||
|
): UsageDao = database.usageDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideUsageCache(
|
fun provideUsageCache(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
dao: UsageDao,
|
||||||
@Named(DataStoreQualifiers.USAGE_CACHE) dataStore: DataStore<Preferences>,
|
@Named(DataStoreQualifiers.USAGE_CACHE) dataStore: DataStore<Preferences>,
|
||||||
json: Json
|
json: Json
|
||||||
): UsageCache = UsageCache(dataStore, json)
|
): UsageCache = UsageCache(context, dao, dataStore, json)
|
||||||
|
|
||||||
// ---- Repositories ----
|
// ---- Repositories ----
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,9 @@ workRuntimeKtx = "2.9.1"
|
|||||||
hilt = "2.59.2"
|
hilt = "2.59.2"
|
||||||
hiltNavigationCompose = "1.2.0"
|
hiltNavigationCompose = "1.2.0"
|
||||||
|
|
||||||
|
# Room
|
||||||
|
room = "2.7.1"
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.2.1"
|
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" }
|
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" }
|
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
|
# Test
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user