Compare commits

...

3 Commits
dev ... v1.6.5

Author SHA1 Message Date
WaterRain
87c3ea20f4
release: bump to v1.6.5
Some checks failed
Release / build (push) Has been cancelled
2026-09-24 05:34:40 +00:00
WaterRain
d205ff2ea4
feat(ocgo): 余额改版为 API Key 直查 /usage 端点,cookie+workspaceId 降级为选填增强
- fetchBalance 改为 GET https://opencode.ai/zen/go/v1/usage(Bearer API Key),移除 dashboard HTML 抓取与 hydration 解析(parseWindows/ScrapedWindow)

- 新增 parseUsageResponse:percent 钳制 0-100,resetsAt 兼容 ISO-8601 与 epoch 秒/毫秒,倒计时可注入基准时间并 coerce >=0

- 错误映射:无 Key/401/403→凭据失效、429→限流(Retry-After)、其余 4xx/5xx→服务端异常,瞬时重试沿用 RetryHelper

- auth cookie + workspaceId 仅保留给用量记录页与 _server 模型级明细增强,缺失时跳过不影响余额主数据

- 设置页表单重排 API Key 主位 + 选填分区标题;测试并保存按钮 enabled 与 ViewModel 校验四象限自洽

- 三语言文案与 README 同步;单测重写为 parseUsageResponse JSON 解析,保留 parseModelRows/windowQuotaRaw

独立 subagent 审计两轮通过(阻断项与按钮自洽性已修复);testDebugUnitTest 160 测试 0 失败
2026-09-22 12:03:10 +00:00
WaterRain
67cb816ec7
feat(ccgo): 用量同步可靠性重构 —— 手动拉满窗口 + 8h 后台兜底 + 进前台增量
- 新增 UsageSyncCoordinator:进程级单飞(Mutex.tryLock)、busy/lastSyncAt/后台开关、8h 前台节流

- 新增 UsageSyncWorker:WorkManager 8h 周期(NetworkType.CONNECTED,唯一任务名),设置页可关

- 手动同步改为 refreshWindow:拉满服务端当前窗口(实测约 24h),不再把「本地已有」当停止信号

- 自动同步走 incrementalSync:保留「整页命中即停」并区分 HIT_EXISTING 与真追平(防服务端将来放开窗口)

- 修复造洞/掩盖洞缺陷:满页判定改用服务端原始条数、触顶/解析异常/游标卡住不再伪装成功、插入计数改用 DAO 返回值

- 响应体读取与 JSON 解析异常不再穿出 fetchPage(前台同步挂在 viewModelScope 上会崩主线程)

- 详情页移除「清除」按钮与确认弹窗;卡片新增「数据可能已过期」提示;设置页新增「后台同步」开关

- 三语言 strings.xml 同步增删文案
2026-09-19 02:35:35 +00:00
25 changed files with 1006 additions and 529 deletions

View File

@ -72,7 +72,7 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family
Download the latest APK from [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases).
> ⚠️ You need a DeepSeek API Key, OpenCode Go login credentials, CommandCode API Key, Codex (OAuth sign-in or pasted auth.json), or an Ollama Cookie to fetch data.
> ⚠️ You need a DeepSeek API Key, an OpenCode Go API Key, a CommandCode API Key, Codex (OAuth sign-in or pasted auth.json), or an Ollama Cookie to fetch data. The OpenCode Go auth cookie and workspaceId are optional — only for usage records and the per-model breakdown.
---

View File

@ -72,7 +72,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
前往 [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases) 下载最新 APK。
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Cookie 才能拉取数据。
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go API Key、CommandCode API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Cookie 才能拉取数据。OpenCode Go 的 auth cookie + workspaceId 选填,仅用于用量记录与模型级明细。
---

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token"
minSdk = 31
targetSdk = 35
versionCode = 14
versionName = "1.6.4"
versionCode = 15
versionName = "1.6.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -223,6 +223,8 @@ dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// 后台用量同步(WorkManager 周期任务):版本已在 libs.versions.toml 中声明
implementation(libs.androidx.work.runtime.ktx)
// Room
implementation(libs.room.runtime)

View File

@ -2,7 +2,10 @@ package com.rainy.token
import android.app.Application
import android.content.Context
import com.rainy.token.domain.usecase.UsageSyncCoordinator
import com.rainy.token.sync.UsageSyncScheduler
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
/**
* RainyToken 入口 Application。
@ -12,6 +15,10 @@ import dagger.hilt.android.HiltAndroidApp
*/
@HiltAndroidApp
class RainyTokenApplication : Application() {
@Inject
lateinit var syncCoordinator: UsageSyncCoordinator
override fun attachBaseContext(base: Context) {
// 应用内语言偏好(跟随系统 / 中文 / English)在 Application 层生效,
// 保证 appContext 与系统级回调(如小组件)之外的代码都使用所选语言。
@ -21,6 +28,10 @@ class RainyTokenApplication : Application() {
override fun onCreate() {
super.onCreate()
appContext = applicationContext
// 后台用量同步兜底:按用户开关注册 / 取消 8 小时周期任务。
// 服务端明细窗口约 24h,只要每天有一次成功同步就不会丢数据;
// 真正的主力是前台进入页面时的自动同步,这里只负责「忘了打开 app」的情况。
UsageSyncScheduler.apply(this, syncCoordinator.backgroundSyncEnabled)
}
companion object {

View File

@ -45,11 +45,17 @@ class UsageCache(
emit(dao.getAll().map { it.toDomain() })
}
suspend fun insertAll(newRecords: List<UsageRecord>) {
/**
* 批量插入,返回**真正写入**的条数。
*
* DAO 使用 OnConflictStrategy.IGNORE:已存在的 id 会被忽略且返回 -1L,可据此精确计数。
* 旧实现用全表 count() 差值统计插入数,会被并发的其它服务同步污染。
*/
suspend fun insertAll(newRecords: List<UsageRecord>): Int {
ensureMigrated()
if (newRecords.isEmpty()) return
if (newRecords.isEmpty()) return 0
val entities = newRecords.map { it.toEntity() }
dao.insertAll(entities)
return dao.insertAll(entities).count { it != -1L }
}
suspend fun getLatest(): UsageRecord? {

View File

@ -21,15 +21,32 @@ import java.util.Locale
import java.util.TimeZone
import javax.inject.Singleton
/**
* 单页抓取结果。
*
* 前两个字段保持 (records, nextCursor) 顺序,兼容既有 `val (records, next) = parse(...)` 解构。
*
* @param rawCount 服务端原始返回条数(解析前),用于判断「是否满页」——不能用 records.size,
* 否则个别记录解析失败会被误判为「到底」并静默截断同步。
* @param droppedCount 解析失败被丢弃的条数,用于把解析异常暴露成同步结果而不是静默吞掉。
*/
data class UsagePage(
val records: List<UsageRecord>,
val nextCursor: String?,
val rawCount: Int = 0,
val droppedCount: Int = 0
)
/**
* CommandCode 用量记录仓库。
*
* 调 JSON API 分页抓取 usage 记录:
* GET https://api.commandcode.ai/internal/usage?limit=50
* GET https://api.commandcode.ai/internal/usage?limit=50&cursor=<base64>
* GET https://api.commandcode.ai/internal/usage?limit=100
* GET https://api.commandcode.ai/internal/usage?limit=100&cursor=<base64>
*
* cursor 是末条记录的 { createdAt, id } 的 base64 编码。
* 第一页不用 cursor。
* cursor 是末条记录的 { createdAt, id } 的 base64 编码(服务端 nextCursor 目前恒为 null,
* 因此由客户端按末条自编码回退)。第一页不用 cursor。
* 注意:服务端只保留最近约 24 小时的明细(滚动窗口),超过窗口的历史无法通过该接口取回。
*/
@Singleton
class CommandCodeUsageRepository(
@ -60,25 +77,30 @@ class CommandCodeUsageRepository(
* - meta 新增 totalCost / inputCost / outputCost / cacheCost / traceId;
* provider / cacheReadInputTokens 已移除。
*/
internal fun parseUsageResponse(body: String): Pair<List<UsageRecord>, String?> {
internal fun parseUsageResponse(body: String): UsagePage {
val root = json.parseToJsonElement(body).jsonObject
val usages = root["usages"]?.jsonArray ?: return emptyList<UsageRecord>() to null
val usages = root["usages"]?.jsonArray
?: return UsagePage(emptyList(), null, 0, 0)
val records = usages.mapNotNull { elem ->
parseUsageObject(elem.jsonObject)
}
val rawCount = usages.size
val dropped = rawCount - records.size
// 新格式:优先用服务端游标;缺失或显式 null 时回退按末条自编码(兼容旧格式)
// 注意:JSON null 的 jsonPrimitive.content 是字符串 "null",必须用 is JsonNull 拦截,
// 否则会把 "null" 当游标传给服务端(被忽略→返回第一页),导致 fullSync 死循环。
val serverCursor = root["nextCursor"]?.takeIf { it !is JsonNull }
?.jsonPrimitive?.content?.takeIf { it.isNotEmpty() }
val nextCursor = serverCursor ?: if (records.size >= PAGE_SIZE) {
val last = records.last()
encodeCursor(last.id, last.timeCreated)
// ★ 满页判定必须用服务端原始条数 rawCount,不能用解析后的 records.size:
// 任意一条记录解析失败就会让解析后条数少 1,旧实现据此判定「到底」并静默中断整次同步,
// 本地因此永久留下空洞(2026-09 实测确认的成因之一)。
val nextCursor = serverCursor ?: if (rawCount >= PAGE_SIZE) {
records.lastOrNull()?.let { encodeCursor(it.id, it.timeCreated) }
} else null
return records to nextCursor
return UsagePage(records, nextCursor, rawCount, dropped)
}
/**
@ -178,9 +200,10 @@ class CommandCodeUsageRepository(
/**
* 获取指定游标页的用量记录。
* cursor=null 为最新页。
* 返回 (记录列表, 下一页游标)。如果返回的列表长度 < PAGE_SIZE,表示到底。
* 返回 [UsagePage]:是否到底由服务端原始条数(rawCount)判定,
* 不能用解析后条数 —— 个别记录解析失败会让解析后条数变少而被误判为「到底」。
*/
suspend fun fetchPage(cursor: String?): Result<Pair<List<UsageRecord>, String?>> =
suspend fun fetchPage(cursor: String?): Result<UsagePage> =
withContext(Dispatchers.IO) {
val cookieHeader = try {
getCookieHeader()
@ -209,7 +232,13 @@ class CommandCodeUsageRepository(
}
response.use { resp ->
val body = resp.body?.string()
val body = try {
resp.body?.string()
} catch (e: IOException) {
// 连接中途断开时 string() 会抛 IOException:不能让异常穿出 fetchPage
//(前台同步挂在 viewModelScope 上,未捕获会直接崩主线程)
return@withContext Result.failure(RepositoryError.Network(e))
}
if (!resp.isSuccessful) {
if (resp.code == 401 || resp.code == 403) {
val detail = if (body != null && body.length < 200) ":$body" else ""
@ -224,8 +253,19 @@ class CommandCodeUsageRepository(
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val records = parseUsageResponse(body)
Result.success(records)
val page = try {
parseUsageResponse(body)
} catch (e: Exception) {
// 200 但响应不是预期 JSON(网关 HTML / 维护页等):降级为解析错误,
// 不能让异常穿出 fetchPage —— 前台同步挂在 viewModelScope 上,会直接崩主线程。
return@withContext Result.failure(
RepositoryError.ParseError(
RepositoryError.ParseErrorReason.MALFORMED_RESPONSE,
e.message ?: "unparsable usage response"
)
)
}
Result.success(page)
}
}
}

View File

@ -5,7 +5,6 @@ import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@ -13,31 +12,32 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.net.URLEncoder
import java.time.Instant
import java.time.OffsetDateTime
import java.time.format.DateTimeParseException
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
/**
* OpenCode Go 配额仓库。计划阶段 5.3(已根据 [slkiser/opencode-quota](https://github.com/slkiser/opencode-quota)
* 调整实现):
* OpenCode Go 配额仓库。
*
* - 用户在浏览器登录 https://opencode.ai/auth(GitHub / Google)
* - 登录后访问 dashboard,URL 形如 `https://opencode.ai/workspace/{workspaceId}/go`
* - 用户从 dashboard URL 中复制 `workspaceId` + 浏览器 DevTools 的 `auth` cookie 值
* - 粘贴到 APP,APP 用 OkHttp 携带 Cookie 抓取该 URL
* - 解析 HTML 中 SolidJS SSR hydration 字段 `rollingUsage` / `weeklyUsage` / `monthlyUsage`
* 余额(5h/周/月 三档窗口)通过 **API Key** 直查官方接口:
* `GET https://opencode.ai/zen/go/v1/usage`(Bearer 鉴权),不再抓取 dashboard HTML。
*
* - API Key:设置页填写(opencode.ai/settings → API Keys),查询余额必填
* - auth cookie + workspaceId:**选填**,仅用于模型级明细增强(`_server` 接口)
* 与 [OpenCodeUsageRepository] 用量记录页;缺失时余额仍正常返回
*
* 不在类上加 @Inject constructor —— 在 [com.rainy.token.di.NetworkModule] 里 @Provides 显式提供。
* 规避 KSP 2.x 多文件 @Inject 跨依赖的"could not be resolved"误报。
@ -50,7 +50,12 @@ class OpenCodeGoRepository(
) {
private val json = Json { ignoreUnknownKeys = true }
/**
* 拉取余额:`GET /zen/go/v1/usage`(Bearer API Key)。
*
* 响应形如 `{"usage":{"rolling":{"percent":42,"resetsAt":"…"},"weekly":{…},"monthly":{…}}}`。
* auth cookie + workspaceId 仅作模型级明细增强,缺失不影响余额主数据。
*/
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
@ -58,20 +63,15 @@ class OpenCodeGoRepository(
if (credential !is Credential.SessionCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
val authCookie = credential.authCookie
val workspaceId = credential.workspaceId
if (authCookie.isNullOrBlank() || workspaceId.isNullOrBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
val apiKey = credential.apiKey?.trim()
if (apiKey.isNullOrEmpty()) {
return@withContext Result.failure(RepositoryError.InvalidCredential("未配置 API Key"))
}
val url = "https://opencode.ai/workspace/${URLEncoder.encode(workspaceId, "UTF-8")}/go"
val request = Request.Builder()
.url(url)
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0")
.header("Accept", "text/html")
.header("Cookie", "auth=$authCookie")
.url(USAGE_API)
.header("Accept", "application/json")
.header("Authorization", "Bearer $apiKey")
.get()
.build()
@ -85,54 +85,48 @@ class OpenCodeGoRepository(
response.use { resp ->
if (!resp.isSuccessful) {
if (resp.code == 401 || resp.code == 403) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
DebugLog.e(TAG, "fetchBalance: HTTP ${resp.code}")
return@withContext when (resp.code) {
401, 403 -> Result.failure(RepositoryError.InvalidCredential("HTTP ${resp.code}"))
429 -> Result.failure(RepositoryError.RateLimited(resp.header("Retry-After")?.toLongOrNull()))
else -> Result.failure(RepositoryError.ServerError(resp.code))
}
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
val html = resp.body?.string() ?: return@withContext Result.failure(
val body = resp.body?.string() ?: return@withContext Result.failure(
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val windows = parseWindows(html)
val now = System.currentTimeMillis()
val windows = parseUsageResponse(body, now)
if (windows.isEmpty()) {
return@withContext Result.failure(
RepositoryError.ParseError(
RepositoryError.ParseErrorReason.NO_WINDOWS,
"解析失败:未找到任何 OpenCode Go 配额窗口。HTML=${html.length} 字节。"
"解析失败:未找到任何 OpenCode Go 配额窗口。body=${body.length} 字节。"
)
)
}
// 主体数据用 rollingUsage(5h 滚动窗口),这是用户最关心的"实时配额"
val primary = windows["rollingUsage"] ?: windows.values.first()
val config = ServiceConfigProvider.get(ServiceType.OPENCODE_GO)
// 把 3 个窗口的用量百分比 + 重置时间全部塞进 extras(详情页按窗口渲染)
// usage/limit 为页面新增的用量与限额字段(单位以服务端定义为准,保留供详情页展示,UI 兼容缺失场景)
// 把 3 个窗口的用量百分比 + 重置倒计时塞进 extras(详情页/小组件按窗口渲染)
val extras = buildMap {
windows["rollingUsage"]?.let { w ->
put("rolling.pct", w.usagePercent.toString())
put("rolling.resetInSec", w.resetInSec.toString())
w.usage?.let { put("rolling.usage", it.toString()) }
w.limit?.let { put("rolling.limit", it.toString()) }
w.resetInSec?.let { put("rolling.resetInSec", it.toString()) }
}
windows["weeklyUsage"]?.let { w ->
put("weekly.pct", w.usagePercent.toString())
put("weekly.resetInSec", w.resetInSec.toString())
w.usage?.let { put("weekly.usage", it.toString()) }
w.limit?.let { put("weekly.limit", it.toString()) }
w.resetInSec?.let { put("weekly.resetInSec", it.toString()) }
}
windows["monthlyUsage"]?.let { w ->
put("monthly.pct", w.usagePercent.toString())
put("monthly.resetInSec", w.resetInSec.toString())
w.usage?.let { put("monthly.usage", it.toString()) }
w.limit?.let { put("monthly.limit", it.toString()) }
w.resetInSec?.let { put("monthly.resetInSec", it.toString()) }
}
}
// 主窗口数据先落缓存并返回(关键路径只依赖 HTML 解析一次请求)
val balance = ServiceBalance(
service = ServiceType.OPENCODE_GO,
amount = primary.usagePercent.toDouble(),
@ -140,13 +134,18 @@ class OpenCodeGoRepository(
isAvailable = true,
monthlySpent = windows["monthlyUsage"]?.usagePercent?.toDouble(),
totalQuota = null,
nextResetAt = System.currentTimeMillis() + primary.resetInSec * 1000L,
nextResetAt = primary.resetAtMillis,
extras = extras
)
balanceCache.put(ServiceType.OPENCODE_GO, balance)
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
// 模型级用量作为增量增强:并行拉取 + 短超时,失败/超时不影响主窗口数据与缓存
// 模型级用量作为增量增强:需要 auth cookie + workspaceId;缺失或失败不影响主数据
val workspaceId = credential.workspaceId
val authCookie = credential.authCookie
if (workspaceId.isNullOrBlank() || authCookie.isNullOrBlank()) {
return@withContext Result.success(balance)
}
val modelUsage = fetchModelWindows(workspaceId, authCookie)
if (modelUsage.isEmpty()) {
return@withContext Result.success(balance)
@ -253,53 +252,68 @@ class OpenCodeGoRepository(
private const val TAG = "OCGO"
private const val MODELS_API = "https://models.dev/api.json"
private const val CHAT_API = "https://opencode.ai/zen/go/v1/chat/completions"
/** 余额/三档窗口用量查询(Bearer API Key) */
private const val USAGE_API = "https://opencode.ai/zen/go/v1/usage"
/** _server 端点:模型级用量接口(f:31 + 窗口参数,需 x-server-id / x-server-instance 头) */
private const val SERVER_ENDPOINT = "https://opencode.ai/_server"
/** 窗口模型用量的 server function id(与请求头 x-server-id 一致,取自网页端实测) */
private const val MODEL_USAGE_SERVER_ID = "ba154d05c4028a885b8c753f9def7e45d87eb982e65fa8b14254cbe636168914"
/** 模型级用量单请求超时(毫秒):只影响增强数据,避免拖慢主窗口刷新 */
private const val MODEL_REQUEST_TIMEOUT_MS = 5_000L
private val SCRAPED_FIELDS = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
/**
* 解析 SolidJS SSR hydration 输出。匹配模式形如:
* `rollingUsage:$R[0]={usagePercent:42,resetInSec:12345}`
* 解析 `GET /zen/go/v1/usage` JSON 响应为三档窗口。
*
* 用精确前缀 "field:$R[" 定位 hydration 数据中的字段声明,
* 避免命中 HTML 其他位置(如 JS 代码注释、模板字符串)的同名文本。
* 用括号计数匹配闭合 "}",正确处理嵌套对象。
* - `percent` 缺失或非数字 → 跳过该窗口(不猜测数值);有值则钳制到 0..100
* - `resetsAt` 支持 ISO-8601 与 epoch(秒/毫秒自动识别);缺失时窗口仍保留(倒计时为 null)
* - 非 JSON / 缺 `usage` 节点 → 空 map(调用方映射 NO_WINDOWS 错误)
*
* @param nowMillis 计算重置倒计时的基准时间(测试可注入)
*/
internal fun parseWindows(html: String): Map<String, ScrapedWindow> {
val result = mutableMapOf<String, ScrapedWindow>()
val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
internal fun parseUsageResponse(body: String, nowMillis: Long): Map<String, UsageWindow> {
val root = try {
Json.parseToJsonElement(body) as? JsonObject
} catch (_: SerializationException) {
null
} ?: return emptyMap()
val usage = root["usage"] as? JsonObject ?: return emptyMap()
for (field in fields) {
// 精确模式:找 "field:$R[N]={" —— 这是 hydration 数据块的独有格式
val keyIdx = html.indexOf("$field:\$R[")
if (keyIdx < 0) continue
val result = mutableMapOf<String, UsageWindow>()
for ((apiField, usageKey) in listOf(
"rolling" to "rollingUsage",
"weekly" to "weeklyUsage",
"monthly" to "monthlyUsage"
)) {
val node = usage[apiField] as? JsonObject ?: continue
val percent = (node["percent"] as? JsonPrimitive)?.content?.toDoubleOrNull()
if (percent == null || !percent.isFinite()) continue
val resetAt = parseResetEpochMillis((node["resetsAt"] as? JsonPrimitive)?.content)
result[usageKey] = UsageWindow(
usagePercent = percent.coerceIn(0.0, 100.0).toFloat(),
resetInSec = resetAt?.let { ((it - nowMillis) / 1000).coerceAtLeast(0L) },
resetAtMillis = resetAt
)
}
return result
}
// 找 '=' 然后找 '{'
val eqIdx = html.indexOf('=', keyIdx)
if (eqIdx < 0 || eqIdx - keyIdx > 60) continue
val braceStart = html.indexOf('{', eqIdx)
if (braceStart < 0 || braceStart - eqIdx > 10) continue
// 括号计数找正确的闭合 "}"(处理嵌套对象)
val braceEnd = findMatchingBrace(html, braceStart) ?: continue
val body = html.substring(braceStart, braceEnd + 1)
val pct = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull()
val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull()
// usage/limit 是页面新增的用量与限额字段(单位以服务端定义为准,缺失不影响窗口识别)
// exactKey=true:避免 "usage" 误命中 "usagePercent" 前缀
val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull()
val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull()
if (pct != null && reset != null) {
result[field] = ScrapedWindow(pct, reset, usage, limit)
/** `resetsAt` → epoch millis:数字(秒/毫秒)或 ISO-8601;无法解析返回 null。 */
private fun parseResetEpochMillis(raw: String?): Long? {
if (raw.isNullOrBlank()) return null
val trimmed = raw.trim()
val numeric = trimmed.toDoubleOrNull()
if (numeric != null && numeric.isFinite() && numeric > 0) {
return if (numeric >= 10_000_000_000.0) numeric.toLong() else (numeric * 1000).toLong()
}
return try {
Instant.parse(trimmed).toEpochMilli()
} catch (_: DateTimeParseException) {
try {
OffsetDateTime.parse(trimmed).toInstant().toEpochMilli()
} catch (_: DateTimeParseException) {
null
}
}
return result
}
/**
@ -458,6 +472,8 @@ class OpenCodeGoRepository(
* 拉取三个窗口(rolling/weekly/monthly)的模型级用量。
* 并行发起 + 每个请求独立短超时(MODEL_REQUEST_TIMEOUT_MS),
* 任一窗口失败不影响其他窗口;整体失败返回空 Map(主窗口数据不受影响)。
*
* 依赖 auth cookie + workspaceId(选填凭据);仅在两者齐备时由 [fetchBalance] 调用。
*/
private suspend fun fetchModelWindows(workspaceId: String, authCookie: String): Map<String, WindowModelUsage> =
coroutineScope {
@ -511,6 +527,18 @@ class OpenCodeGoRepository(
}
}
/**
* 单个用量窗口(`GET /zen/go/v1/usage` 解析结果)。
* @param usagePercent 已用百分比(0..100)
* @param resetInSec 距离重置的倒计时秒数(相对 nowMillis,已钳制 ≥0;null = 接口未返回重置时间)
* @param resetAtMillis 重置时刻(epoch millis;null = 接口未返回)
*/
internal data class UsageWindow(
val usagePercent: Float,
val resetInSec: Long? = null,
val resetAtMillis: Long? = null
)
/**
* 窗口模型级用量(_server 接口 payload)。
* cost/quotaCost 单位为 1e-8 美元;multiplier 为计费倍率(quotaCost = cost × multiplier)。

View File

@ -37,13 +37,13 @@ sealed class Credential {
val token: String? = null,
/** Token 过期时间(epoch millis),null 表示未知。 */
val expiresAt: Long? = null,
/** OpenCode Go 专用:dashboard auth cookie 值(简化为单字段,便于用户粘贴) */
/** OpenCode Go(选填):dashboard auth cookie 值,仅用于用量记录与模型级明细(简化为单字段,便于用户粘贴) */
val authCookie: String? = null,
/** OpenCode Go 专用:workspace ID */
/** OpenCode Go(选填):workspace ID,仅用于用量记录与模型级明细 */
val workspaceId: String? = null,
/** Ollama 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */
val ollamaCookie: String? = null,
/** OpenCode Go / Ollama 专用:API Key,用于一键激活用量 */
/** OpenCode Go 主凭据 / Ollama:API Key(OpenCode Go 用它查余额 + 一键激活;Ollama 用于一键激活) */
val apiKey: String? = null,
override val lastVerifiedAt: Long = 0L
) : Credential()

View File

@ -10,22 +10,27 @@ import javax.inject.Provider
*
* 游标协议:每页返回 (记录列表, 下一页游标)。
* - cursor=null → 最新页
* - 返回的列表长度 < PAGE_SIZE → 到底
* - 服务端 nextCursor 缺失时按末条自编码回退;「是否到底」以服务端原始条数为准
*
* ## 全量同步
* 从 cursor=null 逐页抓取直至最后一页。
* ## 三种同步的分工
* - [refreshWindow] 立即同步(用户主动点刷新):从 cursor=null 连续向前翻,拉满服务端窗口,
* 不把「本地已有」当停止信号,能补齐窗口内被服务端补录 / 曾漏掉的记录;
* - [incrementalSync] 增量(自动路径:回到前台 / 8h 后台任务):**整页记录本地已存在即停**,
* 健康状态下 1 个请求即可,省请求,但探不到窗口内的空洞;
* - [fullSync] 全量:逐页抓到服务端没有更多。
*
* ## 增量同步
* 从 cursor=null 逐页抓取,每页比对本地已有 ID,
* 当某页全部记录都已存在时停止。
* 注意:「整页命中即停」只说明最新一页本地已有,**不代表窗口完整**——补洞要用 [refreshWindow]。
*/
class SyncCommandCodeUsageUseCase @Inject constructor(
private val usageRepoProvider: Provider<CommandCodeUsageRepository>,
private val cacheProvider: Provider<UsageCache>
) {
/** 防御性页数上限:正常窗口(1 天 ≤ 数十页)远不会触及,防止游标异常导致死循环 */
private companion object {
/** 全量拉取的防御性页数上限:防止游标异常导致死循环 */
const val MAX_PAGES = 500
/** 立即同步(refreshWindow)的页数上限:100 条/页 → 5000 条;服务端 24h 窗口远小于此 */
const val MAX_WINDOW_PAGES = 50
}
suspend fun fullSync(): Result<SyncResult> {
@ -42,53 +47,203 @@ class SyncCommandCodeUsageUseCase @Inject constructor(
errors.add("cursor=${cursor?.take(20)}: ${pageResult.exceptionOrNull()?.message}")
break
}
val (records, nextCursor) = pageResult.getOrThrow()
if (records.isEmpty()) break
if (nextCursor == cursor) break // 游标未前进,防死循环
val page = pageResult.getOrThrow()
if (page.records.isEmpty() && page.rawCount > 0) {
// 服务端有数据、但整页都解析失败:不能当作「到底」,否则会静默漏掉整页
errors.add("parse anomaly: raw=${page.rawCount} dropped=${page.droppedCount}")
break
}
if (page.records.isEmpty()) break
if (page.nextCursor == cursor) break // 游标未前进,防死循环
val before = cache.count()
cache.insertAll(records)
totalInserted += (cache.count() - before)
// 精确计数:DAO 的 IGNORE 策略下已存在的行返回 -1;
// 旧实现用全表 count() 差值,会被并发的其它服务同步污染。
totalInserted += cache.insertAll(page.records)
if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break
cursor = nextCursor
if (++pages >= MAX_PAGES) break // 防御性上限,正常窗口不会触及
cursor = page.nextCursor
if (cursor == null) break // 服务端没有更多(正常到底)
if (++pages >= MAX_PAGES) {
// 触顶 = 行走被截断,不能算成功:旧实现静默返回 success,会把空洞伪装成「已同步」
errors.add("page limit $MAX_PAGES reached")
break
}
}
return if (errors.isEmpty()) Result.success(SyncResult(inserted = totalInserted))
else Result.failure(SyncError.PartialSync(totalInserted, errors))
return if (errors.isEmpty()) Result.success(
SyncResult(inserted = totalInserted, pages = pages, stopReason = SyncStopReason.BOTTOM)
) else Result.failure(SyncError.PartialSync(totalInserted, errors))
}
/**
* 立即同步(用户主动触发):把服务端当前窗口内的记录一次性拉满。
*
* 与 [incrementalSync] 的分工:这里**不把「本地已有」当作停止信号**,
* 因此能补上窗口内被服务端补录 / 曾漏掉的记录(代价是多几个请求)。
* 服务端只保留最近约 24 小时的明细(滚动窗口),窗口之外的历史无法回补。
* 从最新页连续向前翻,直到:服务端没有更多 / 游标不前进 / 触顶 / 出错。
*/
suspend fun refreshWindow(): Result<SyncResult> {
val repo = usageRepoProvider.get()
val cache = cacheProvider.get()
var cursor: String? = null
var inserted = 0
var scanned = 0
var dropped = 0
var pages = 0
val errors = mutableListOf<String>()
var stop = SyncStopReason.BOTTOM
while (true) {
val pageResult = repo.fetchPage(cursor)
if (pageResult.isFailure) {
val cause = pageResult.exceptionOrNull()
// 首页(cursor 为 null)就失败:此时一条记录都还没写入,直接把原异常冒泡——
// UI 能显示精确文案(凭据失效 / 网络异常 / 服务端错误 / 限流),
// 后台 Worker 也能据此判断该不该重试。否则会被 PartialSync 吞成「部分同步」。
if (cursor == null && cause != null) {
return Result.failure(cause)
}
errors.add("cursor=${cursor?.take(20)}: ${cause?.message}")
stop = SyncStopReason.PAGE_ERROR
break
}
val page = pageResult.getOrThrow()
pages++
scanned += page.rawCount
dropped += page.droppedCount
if (page.records.isEmpty() && page.rawCount > 0) {
// 服务端有数据但整页解析失败:不当作「到底」,而是显式中断并上报
stop = SyncStopReason.PARSE_ANOMALY
break
}
inserted += cache.insertAll(page.records)
val next = page.nextCursor
if (next == null) { stop = SyncStopReason.BOTTOM; break }
if (next == cursor) { stop = SyncStopReason.CURSOR_STUCK; break }
if (pages >= MAX_WINDOW_PAGES) { stop = SyncStopReason.MAX_PAGES; break }
cursor = next
}
val result = SyncResult(
inserted = inserted,
scanned = scanned,
dropped = dropped,
pages = pages,
stopReason = stop
)
// 只有「真追平服务端窗口」才算成功:被页数上限截断、服务端数据解析异常、游标卡住
// 都属于「没拉完」,必须上报——否则协调器会刷新 lastSyncAt、把窗口守护清掉,
// 用户看到的是「同步成功、新增 0 条」,洞却被悄悄留着。
// 部分解析失败:只有丢失显著(≥10 条,或 ≥ 本次扫描的一半)才按「数据没拿全」上报。
// 偶发个别脏记录不足以让整次同步失败——否则 lastSyncAt 会永久冻住、窗口守护常挂、后台还会无限重试。
val droppedSignificantly = dropped >= 10 || (scanned > 0 && dropped * 2 >= scanned)
val abnormal = when {
stop == SyncStopReason.MAX_PAGES -> "page limit $MAX_WINDOW_PAGES reached"
stop == SyncStopReason.PARSE_ANOMALY -> "parse anomaly: dropped=$dropped"
stop == SyncStopReason.CURSOR_STUCK -> "cursor did not advance"
droppedSignificantly -> "dropped $dropped of $scanned record(s) while parsing"
else -> null
}
return if (errors.isEmpty() && abnormal == null) Result.success(result)
else Result.failure(
SyncError.PartialSync(inserted, errors + listOfNotNull(abnormal))
)
}
/**
* 增量同步(自动同步用):从最新页向前翻,**某页记录本地全部已存在时停止**。
*
* 保留这个启发式的理由:健康状态下只要 1 个请求就能确认「已接到已知数据」,
* 前台与后台每天会触发多次,这是成本最低的做法。
*
* 与 [refreshWindow] 的分工:
* - 增量(本方法):自动路径(回到前台、8h 后台任务)——省请求,但不保证填洞;
* - 窗口拉满:[refreshWindow] ——用户主动点「立即同步」时用,多几个请求换「窗口内补齐」。
*
* 与旧实现的差别(旧实现的两个缺陷):
* 1. 不再用「解析后条数」判断满页/到底 —— 一条记录解析失败曾会导致整页被当成已知数据;
* 2. 命中即停会作为 [SyncStopReason.HIT_EXISTING] 上报,不再与「窗口真拉完」混为一谈。
*
* 注意:命中即停只说明「最新一页本地已有」,**不代表窗口内没有空洞**;它不算失败(返回 success),
* 但也不应被当作「服务端窗口已拉完」——补洞要靠 [refreshWindow]。
*/
suspend fun incrementalSync(): Result<SyncResult> {
val repo = usageRepoProvider.get()
val cache = cacheProvider.get()
var cursor: String? = null
var totalInserted = 0
var inserted = 0
var scanned = 0
var dropped = 0
var pages = 0
val errors = mutableListOf<String>()
var stop = SyncStopReason.BOTTOM
// 行程开始前取一次本地已有 ID(避免每页重复扫全表;本行程新插入的 id 不会再出现在后续页)
val existingIds = cache.getIdsByWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID)
while (true) {
val pageResult = repo.fetchPage(cursor)
if (pageResult.isFailure) return Result.failure(pageResult.exceptionOrNull()!!)
if (pageResult.isFailure) {
val cause = pageResult.exceptionOrNull()
// 首页失败:直接冒泡原异常(UI 文案精确,后台可据此判断是否值得重试)
if (cursor == null && cause != null) return Result.failure(cause)
errors.add("cursor=${cursor?.take(20)}: ${cause?.message}")
stop = SyncStopReason.PAGE_ERROR
break
}
val (records, nextCursor) = pageResult.getOrThrow()
if (records.isEmpty()) break
if (nextCursor == cursor) break // 游标未前进,防死循环
val page = pageResult.getOrThrow()
pages++
scanned += page.rawCount
dropped += page.droppedCount
// 按 workspace 过滤本地已有 ID,避免跨 workspace 碰撞
val workspaceId = records.firstOrNull()?.workspaceId ?: CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val existingIds = cache.getIdsByWorkspace(workspaceId)
val newRecords = records.filter { it.id !in existingIds }
if (page.records.isEmpty() && page.rawCount > 0) {
// 服务端有数据但整页解析失败:按异常处理,不停成「已接到旧数据」
stop = SyncStopReason.PARSE_ANOMALY
break
}
if (newRecords.isEmpty()) break
// 整页命中判定基于服务端原始条数:个别记录解析失败不应让整页被误判为「已存在」
val newRecords = page.records.filter { it.id !in existingIds }
cache.insertAll(newRecords)
totalInserted += newRecords.size
if (page.rawCount > 0 && newRecords.isEmpty()) {
// 整页都是已知数据 → 已接到旧数据,停止(保留既有启发式)
stop = SyncStopReason.HIT_EXISTING
break
}
if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break
cursor = nextCursor
if (++pages >= MAX_PAGES) break // 防御性上限
inserted += cache.insertAll(newRecords)
val next = page.nextCursor
if (next == null) { stop = SyncStopReason.BOTTOM; break }
if (next == cursor) { stop = SyncStopReason.CURSOR_STUCK; break }
if (pages >= MAX_WINDOW_PAGES) { stop = SyncStopReason.MAX_PAGES; break }
cursor = next
}
return Result.success(SyncResult(inserted = totalInserted))
val result = SyncResult(
inserted = inserted,
scanned = scanned,
dropped = dropped,
pages = pages,
stopReason = stop
)
// 命中即停 / 真追平都算成功;只有被截断、显著解析丢失或遇到异常才上报失败。
// (个别脏记录不足以让整次同步失败,否则 lastSyncAt 会冻住、窗口守护常挂、后台会无限重试)
val droppedSignificantly = dropped >= 10 || (scanned > 0 && dropped * 2 >= scanned)
val abnormal = when {
stop == SyncStopReason.MAX_PAGES -> "page limit $MAX_WINDOW_PAGES reached"
stop == SyncStopReason.PARSE_ANOMALY -> "parse anomaly: dropped=$dropped"
stop == SyncStopReason.CURSOR_STUCK -> "cursor did not advance"
droppedSignificantly -> "dropped $dropped of $scanned record(s) while parsing"
else -> null
}
return if (errors.isEmpty() && abnormal == null) Result.success(result)
else Result.failure(
SyncError.PartialSync(inserted, errors + listOfNotNull(abnormal))
)
}
}

View File

@ -79,16 +79,41 @@ class SyncUsageUseCase @Inject constructor(
}
}
/** 同步停止原因:把「真追平」与「被截断 / 遇到异常」区分开(旧实现不可区分,空洞因此无法诊断)。 */
enum class SyncStopReason {
/** 服务端已经没有更多记录(真追平服务端窗口) */
BOTTOM,
/** 增量同步:整页记录本地都已存在(已接到已知数据),按启发式停在这里 */
HIT_EXISTING,
CURSOR_STUCK,
MAX_PAGES,
PAGE_ERROR,
PARSE_ANOMALY
}
data class SyncResult(
val inserted: Int,
val totalCount: Int = 0
val totalCount: Int = 0,
/** 本次扫过的服务端记录数(含已存在的) */
val scanned: Int = 0,
/** 解析失败被丢弃的条数,>0 表示服务端有本地解析不了的数据 */
val dropped: Int = 0,
val pages: Int = 0,
val stopReason: SyncStopReason = SyncStopReason.BOTTOM
)
sealed class SyncError : Exception() {
class PartialSync(val inserted: Int, val errors: List<String>) : SyncError() {
override val message: String = buildString {
append("部分同步完成:插入 ${inserted}条,${errors.size}页失败")
if (errors.isNotEmpty()) append("。${errors.first().take(200)}")
}
}
/** 已有同步任务在跑(进程级互斥),本次请求被跳过。 */
class AlreadyRunning : SyncError() {
override val message: String = "同步正在进行中"
}
class PartialSync(val inserted: Int, val errors: List<String>) : SyncError() {
override val message: String = buildString {
append("部分同步完成:插入 ${inserted}条,${errors.size}页失败")
if (errors.isNotEmpty()) append("。${errors.first().take(200)}")
}
}
}

View File

@ -0,0 +1,112 @@
package com.rainy.token.domain.usecase
import android.content.Context
import android.content.SharedPreferences
import com.rainy.token.data.repository.CommandCodeUsageRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/**
* 用量同步协调器:进程级单飞 + 同步状态持久化。
*
* 为什么必须单独存在这一层:
* - 同一 workspace 会同时存在多个 UsageViewModel 实例(仪表盘卡片、详情页、概览页、
* 双栏详情各自持有 ViewModelStore),ViewModel 内部的 syncing 标志无法互斥,
* 多入口同时同步会重复请求并互相竞争。
* - UseCase 不是 @Singleton(调用方用 Provider.get() 每次新建实例),
* 实例内的 Mutex 不共享,所以互斥必须放在 @Singleton 的这一层。
*
* 顺带统一管理(沿用既有 prefs,不破坏老版本已写入的数据):
* - 最近一次成功同步时间(usage_sync_at / sync_at_<workspaceId>)
* - 后台同步开关(usage_sync_at / background_sync_enabled)
*/
@Singleton
class UsageSyncCoordinator @Inject constructor(
@ApplicationContext context: Context,
private val ccgoSyncProvider: Provider<SyncCommandCodeUsageUseCase>
) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val syncMutex = Mutex()
private val _busy = MutableStateFlow(false)
/** 是否有同步正在进行:跨页面共享,让所有入口的刷新按钮一起转圈。 */
val busy: StateFlow<Boolean> = _busy.asStateFlow()
/**
* CCGO 同步(进程级单飞)。
*
* @param incremental true = 增量同步(整页命中即停,自动路径用:回到前台 / 8h 后台任务,省请求)
* false = 把服务端窗口拉满(用户主动点「立即同步」用,能把窗口内的洞补齐)
*
* 并发调用时直接返回 [SyncError.AlreadyRunning],不排队、不伪装成功。
*/
suspend fun syncCcgoUsage(incremental: Boolean): Result<SyncResult> {
if (!syncMutex.tryLock()) {
return Result.failure(SyncError.AlreadyRunning())
}
_busy.value = true
return try {
val useCase = ccgoSyncProvider.get()
val result = if (incremental) useCase.incrementalSync() else useCase.refreshWindow()
if (result.isSuccess) {
saveLastSyncAt(
CommandCodeUsageRepository.CCGO_WORKSPACE_ID,
System.currentTimeMillis()
)
}
result
} finally {
_busy.value = false
syncMutex.unlock()
}
}
// ===== 同步时间 =====
fun lastSyncAt(workspaceId: String): Long = prefs.getLong(syncAtKey(workspaceId), 0L)
/** 距上次成功同步是否已超过 [maxAgeMs];从未成功同步过也算过期。 */
fun isSyncStale(workspaceId: String, maxAgeMs: Long = STALE_AFTER_MS): Boolean {
val last = lastSyncAt(workspaceId)
return last <= 0L || System.currentTimeMillis() - last > maxAgeMs
}
fun saveLastSyncAt(workspaceId: String, ts: Long) {
prefs.edit().putLong(syncAtKey(workspaceId), ts).apply()
}
// ===== 后台同步开关 =====
var backgroundSyncEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKGROUND_SYNC, true)
set(value) {
prefs.edit().putBoolean(KEY_BACKGROUND_SYNC, value).apply()
}
companion object {
/** 沿用既有 prefs 名与 key 格式,保证老版本写入的同步时间不丢失 */
private const val PREFS_NAME = "usage_sync_at"
private const val KEY_PREFIX = "sync_at_"
private const val KEY_BACKGROUND_SYNC = "background_sync_enabled"
/**
* 窗口守护阈值:超过它没有成功同步,UI 提示「数据可能已过期」。
* 服务端窗口约 24h,取 12h 留出一次补救机会。
*/
const val STALE_AFTER_MS = 12 * 60 * 60 * 1000L
/** 前台进入页面时的自动同步最小间隔(距上次成功同步超过它才自动同步)。 */
const val FOREGROUND_MIN_INTERVAL_MS = 8 * 60 * 60 * 1000L
private fun syncAtKey(workspaceId: String) = KEY_PREFIX + workspaceId
}
}

View File

@ -0,0 +1,94 @@
package com.rainy.token.sync
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.domain.usecase.SyncError
import com.rainy.token.domain.usecase.UsageSyncCoordinator
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.util.concurrent.TimeUnit
/** Hilt EntryPoint:让 Worker(非 Hilt 组件)拿到 @Singleton 的同步协调器。 */
@EntryPoint
@InstallIn(SingletonComponent::class)
interface UsageSyncEntryPoint {
fun usageSyncCoordinator(): UsageSyncCoordinator
}
/**
* 后台用量同步 Worker。
*
* 定位:**兜底**。WorkManager 不保证准时(Doze / 厂商省电会把它推迟,app 被强行停止后
* 要等用户手动打开才恢复),所以真正的主力是前台进入页面时的自动同步。
* 但只要一天里任意一层成功同步过一次,就不会丢数据(服务端明细窗口约 24h)。
*/
class UsageSyncWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val coordinator = EntryPointAccessors
.fromApplication(applicationContext, UsageSyncEntryPoint::class.java)
.usageSyncCoordinator()
// 用户在设置里关掉了后台同步 → 直接成功退出(不重试、不报警)
if (!coordinator.backgroundSyncEnabled) return Result.success()
// 自动路径走增量同步(整页命中即停):健康状态下 1 个请求就能确认「已接到已知数据」,
// 省电省流量;补窗口内的洞交给用户主动点的「立即同步」(refreshWindow)。
val result = coordinator.syncCcgoUsage(incremental = true)
return when (val error = result.exceptionOrNull()) {
null -> Result.success()
// 前台正在同步:不算失败,别和前台抢
is SyncError.AlreadyRunning -> Result.success()
// 凭据失效 / 被替换 / 服务端响应无法解析:重试一万次也不会成功,直接结束(避免无谓耗电)
is RepositoryError.InvalidCredential,
is RepositoryError.CredentialChanged,
is RepositoryError.ParseError -> Result.success()
// 网络波动 / 服务端错误 / 限流(429):交给 WorkManager 的退避重试
else -> Result.retry()
}
}
}
/**
* 后台同步调度:每 8 小时一次,仅在网络可用时执行。
*
* 用唯一任务名 + KEEP,重复调用不会堆积任务(例如每次冷启动都调用 apply())。
*/
object UsageSyncScheduler {
private const val WORK_NAME = "usage_sync_ccgo"
private const val INTERVAL_HOURS = 8L
fun apply(context: Context, enabled: Boolean) {
val workManager = WorkManager.getInstance(context)
if (!enabled) {
workManager.cancelUniqueWork(WORK_NAME)
return
}
val request = PeriodicWorkRequestBuilder<UsageSyncWorker>(
INTERVAL_HOURS, TimeUnit.HOURS
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
).build()
workManager.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
}

View File

@ -228,7 +228,7 @@ private fun CompactNavHost() {
onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) },
onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) },
viewModel = chartVm,
clearViewModel = usageVm
isCcgo = true
)
}
composable(Routes.CCGO_USAGE_OVERVIEW) {
@ -433,7 +433,7 @@ private fun ExpandedDetailPane(
onOpenOverview = { navController.navigate("overview") },
onOpenData = { navController.navigate("data") },
viewModel = chartVm,
clearViewModel = usageVm
isCcgo = true
)
}
composable("overview") {

View File

@ -28,6 +28,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.ui.components.asString
@ -49,9 +51,14 @@ fun CommandCodeUsageStatsCard(
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val key = "ccgo_$wid"
val viewModel: UsageViewModel = hiltViewModel(key = key)
// 初始化 workspace,后续刷新自动走 CCGO 的 sync use case
LaunchedEffect(Unit) {
// 初始化 workspace(幂等),后续刷新自动走 CCGO 的 sync use case
LaunchedEffect(Unit) { viewModel.setWorkspace(wid) }
// 进入 / 回到前台时若距上次成功同步超过阈值,自动补一次(前台主力防线)。
// 用 ON_RESUME 而不是只靠首次组合:用户从后台切回来同样要能触发。
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.setWorkspace(wid)
viewModel.syncIfStale()
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -181,6 +188,16 @@ fun CommandCodeUsageStatsCard(
)
}
// ─── 窗口守护提示:服务端明细只保留约 24h,超过 12h 未成功同步就有永久丢失风险 ───
if (uiState.syncStale) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "⚠︎ " + stringResource(R.string.usage_sync_stale),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}

View File

@ -80,19 +80,18 @@ fun UsageDetailScreen(
onBack: () -> Unit,
onOpenOverview: () -> Unit,
onOpenData: () -> Unit = {},
viewModel: UsageChartViewModel = hiltViewModel(),
clearViewModel: UsageViewModel? = null // non-null = CCGO, 显示清除按钮
/** true = CCGO 详情(数据由 NavHost 的 setWorkspace 触发加载,不重复 load) */
isCcgo: Boolean = false,
viewModel: UsageChartViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
// OCGO 首次加载(CCGO 由 NavHost 的 setWorkspace 触发,不重复 load)
LaunchedEffect(Unit) {
if (clearViewModel == null) viewModel.load()
if (!isCcgo) viewModel.load()
}
var showClearDialog by remember { mutableStateOf(false) }
var clearCountdown by remember { mutableStateOf(0) }
var granularityExpanded by remember { mutableStateOf(false) }
var modelExpanded by remember { mutableStateOf(false) }
var showCustomDayPicker by remember { mutableStateOf(false) }
@ -110,7 +109,7 @@ fun UsageDetailScreen(
topBar = {
TopAppBar(
title = {
val serviceLabel = if (clearViewModel == null) "OCGO" else "CommandCode"
val serviceLabel = if (isCcgo) "CommandCode" else "OCGO"
Column {
Text(
stringResource(R.string.title_usage_detail),
@ -128,11 +127,6 @@ fun UsageDetailScreen(
},
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } },
actions = {
if (clearViewModel != null) {
TextButton(onClick = { showClearDialog = true }) {
Text(stringResource(R.string.action_clear), color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
}
}
TextButton(onClick = onOpenData) {
Text(stringResource(R.string.action_view_raw_data), color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
}
@ -362,49 +356,7 @@ fun UsageDetailScreen(
DetailRow(stringResource(R.string.chart_stack_input), formatTokenComma(state.buckets.sumOf { it.inputTokens }))
DetailRow(stringResource(R.string.chart_stack_output), formatTokenComma(state.buckets.sumOf { it.outputTokens }))
}
if (showClearDialog && clearViewModel != null) {
val cd = clearCountdown
androidx.compose.material3.AlertDialog(
onDismissRequest = { showClearDialog = false; clearCountdown = 0 },
title = { Text("⚠️ " + stringResource(R.string.dialog_clear_title), fontWeight = FontWeight.Bold) },
text = {
Column {
Text(stringResource(R.string.dialog_clear_body))
Spacer(Modifier.height(12.dp))
Text(stringResource(R.string.dialog_clear_confirm_question), fontWeight = FontWeight.SemiBold)
}
},
confirmButton = {
TextButton(
onClick = {
showClearDialog = false
clearCountdown = 0
clearViewModel?.clearAndResync()
onBack()
},
enabled = cd == 0
) {
Text(
if (cd > 0) stringResource(R.string.action_confirm_countdown, cd) else stringResource(R.string.action_confirm_clear),
color = if (cd == 0) MaterialTheme.colorScheme.error else InkMuted
)
}
},
dismissButton = {
TextButton(onClick = { showClearDialog = false; clearCountdown = 0 }) {
Text(stringResource(R.string.action_cancel))
}
}
)
LaunchedEffect(showClearDialog) {
if (!showClearDialog) return@LaunchedEffect
clearCountdown = 3
for (i in 3 downTo 1) {
kotlinx.coroutines.delay(1000)
clearCountdown = i - 1
}
}
}
// 「清除」入口已移除:服务端明细只保留约 24h,清空本地缓存无法补回历史,只会永久丢失现有记录
}
}

View File

@ -12,9 +12,10 @@ import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.SyncCommandCodeUsageUseCase
import com.rainy.token.domain.usecase.SyncError
import com.rainy.token.domain.usecase.SyncResult
import com.rainy.token.domain.usecase.SyncUsageUseCase
import com.rainy.token.domain.usecase.UsageSyncCoordinator
import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@ -78,8 +79,8 @@ sealed class TimeFilter(@StringRes val labelRes: Int) {
class UsageViewModel @Inject constructor(
private val cacheProvider: Provider<UsageCache>,
private val syncUseCaseProvider: Provider<SyncUsageUseCase>,
private val syncCommandCodeUseCaseProvider: Provider<SyncCommandCodeUsageUseCase>,
private val credentialRepository: CredentialRepository
private val credentialRepository: CredentialRepository,
private val syncCoordinator: UsageSyncCoordinator
) : ViewModel() {
private val _uiState = MutableStateFlow(UsageUiState())
@ -88,24 +89,29 @@ class UsageViewModel @Inject constructor(
private var workspaceIdOverride: String? = null
private var loadGeneration = 0 // 递增:过时的 loadStatsInternal 结果自动丢弃
/** 用量同步时间持久化(按 workspace 区分 OCGO / CCGO),重启后卡片仍能显示刷新时间。 */
private fun syncAtPrefs(): android.content.SharedPreferences =
com.rainy.token.RainyTokenApplication.appContext.getSharedPreferences(
"usage_sync_at",
android.content.Context.MODE_PRIVATE
)
private fun syncAtKey(wid: String): String = "sync_at_$wid"
private fun loadLastSyncAt(wid: String): Long =
syncAtPrefs().getLong(syncAtKey(wid), 0L)
private fun saveLastSyncAt(wid: String, ts: Long) {
syncAtPrefs().edit().putLong(syncAtKey(wid), ts).apply()
init {
// 跨页面共享的同步状态(进程级单飞):协调器在跑时,CCGO 各页面的刷新按钮一起转圈;
// 后台 Worker 完成同步(busy 由真→假)时补一次数据重载,否则界面会停在旧数字上。
viewModelScope.launch {
var wasBusy = false
syncCoordinator.busy.collect { busy ->
if (workspaceIdOverride == com.rainy.token.data.repository
.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
) {
_uiState.update { it.copy(syncing = busy) }
if (wasBusy && !busy) loadStats()
}
wasBusy = busy
}
}
}
// 同步时间与后台同步开关统一由 [UsageSyncCoordinator] 持久化(沿用原 prefs:usage_sync_at / sync_at_<wid>)
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadStats() 前调用。 */
fun setWorkspace(wid: String) {
// 幂等:进入 / 回到前台会重复调用,同值不重置状态,避免卡片闪一下空数据
if (workspaceIdOverride == wid) return
workspaceIdOverride = wid
loadGeneration++
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下
@ -175,7 +181,8 @@ class UsageViewModel @Inject constructor(
recordCount = totalCount,
loading = false,
dailyPage = 1,
lastSyncAt = loadLastSyncAt(wid)
lastSyncAt = syncCoordinator.lastSyncAt(wid),
syncStale = syncCoordinator.isSyncStale(wid)
)
}
}
@ -203,65 +210,80 @@ class UsageViewModel @Inject constructor(
_uiState.update { it.copy(dailyPage = (it.dailyPage + 1).coerceAtMost(totalPages)) }
}
fun sync() {
/**
* 手动同步(刷新按钮 / 下拉刷新):默认把服务端窗口拉满,能把窗口内的空洞补齐。
*
* 自动路径([syncIfStale]、后台 Worker)传 [incremental] = true 走增量(整页命中即停),省请求。
*/
fun sync(incremental: Boolean = false) {
viewModelScope.launch {
_uiState.update { it.copy(syncing = true) }
val wid = workspaceIdOverride ?: workspaceId()
val isCcgo = workspaceIdOverride == com.rainy.token.data.repository
.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val result = withContext(Dispatchers.Default) {
val cache = cacheProvider.get()
if (workspaceIdOverride == com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID) {
val useCase = syncCommandCodeUseCaseProvider.get()
val count = cache.count(com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID)
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
if (isCcgo) {
syncCoordinator.syncCcgoUsage(incremental = incremental)
} else {
// OCGO:保持既有分页同步(服务端窗口与游标语义不同,本次不动)
val useCase = syncUseCaseProvider.get()
val count = cache.count()
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
val cache = cacheProvider.get()
if (cache.count() == 0) useCase.fullSync() else useCase.incrementalSync()
}
}
val syncedAt = System.currentTimeMillis()
if (result.isSuccess && wid != null) saveLastSyncAt(wid, syncedAt)
result.onSuccess { loadStats() }
val error = result.exceptionOrNull()
// 已有同步在跑(其它页面 / 后台 Worker):不清空上次结果,按钮状态交给协调器的 busy 驱动。
if (error is SyncError.AlreadyRunning) {
// 极小概率竞态:busy 刚转 false 又被判定为「正在跑」,此时不会有人再复位 syncing
_uiState.update { it.copy(syncing = syncCoordinator.busy.value) }
return@launch
}
// OCGO 不走协调器,成功时间要在这里补写(否则卡片的「更新于 X」会永远停在旧值)
if (result.isSuccess && !isCcgo && wid != null) {
syncCoordinator.saveLastSyncAt(wid, System.currentTimeMillis())
}
// 失败(尤其 PartialSync)时通常也已经写入了一部分记录:必须重新加载,
// 否则界面停在旧数据上,用户会以为「刷新没生效」。
// 这里无条件刷新(多一次全表统计读,代价很小);不依赖 busy 跳变,避免 StateFlow 合并导致漏刷新。
loadStats()
_uiState.update {
it.copy(
syncing = false,
lastSyncResult = result.getOrNull()?.inserted ?: 0,
lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) },
lastSyncAt = if (result.isSuccess && wid != null) syncedAt else it.lastSyncAt
lastSyncResult = syncInsertedCount(result),
lastSyncError = error?.let { syncErrorToUiText(it) },
lastSyncAt = if (result.isSuccess && wid != null) syncCoordinator.lastSyncAt(wid)
else it.lastSyncAt
)
}
}
}
/** 清除当前 workspace 的缓存数据并全量重新同步。 */
fun clearAndResync() {
viewModelScope.launch {
val wid = workspaceIdOverride ?: return@launch
_uiState.update { it.copy(syncing = true) }
val cache = cacheProvider.get()
cache.deleteByWorkspaceId(wid)
invalidateData()
val useCase = syncCommandCodeUseCaseProvider.get()
val result = useCase.fullSync()
val syncedAt = System.currentTimeMillis()
if (result.isSuccess) saveLastSyncAt(wid, syncedAt)
result.onSuccess { loadStats() }
_uiState.update {
it.copy(
syncing = false,
lastSyncResult = result.getOrNull()?.inserted ?: 0,
lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) },
lastSyncAt = if (result.isSuccess) syncedAt else it.lastSyncAt
)
}
/**
* 进入 / 回到前台时的自动同步(主力防线)。
*
* 距上次成功同步超过 [UsageSyncCoordinator.FOREGROUND_MIN_INTERVAL_MS](8 小时)才真正发起,
* 避免每次切页面都打网络;服务端明细窗口约 24h,这一层保证「每天打开过就不会丢」。
* 走增量同步(整页命中即停),省请求;补洞交给用户主动点的「立即同步」。
*/
fun syncIfStale() {
val wid = workspaceIdOverride ?: return
if (syncCoordinator.isSyncStale(wid, UsageSyncCoordinator.FOREGROUND_MIN_INTERVAL_MS)) {
sync(incremental = true)
}
}
private fun invalidateData() {
_uiState.value = UsageUiState()
}
}
/** 同步结果里的新增条数:成功取 result,部分失败时从 PartialSync 异常里取(旧实现失败一律显示 0)。 */
private fun syncInsertedCount(result: Result<SyncResult>): Int =
result.getOrNull()?.inserted
?: (result.exceptionOrNull() as? SyncError.PartialSync)?.inserted
?: 0
data class UsageUiState(
val loading: Boolean = true,
val syncing: Boolean = false,
@ -272,6 +294,8 @@ data class UsageUiState(
val lastSyncResult: Int = 0,
val lastSyncError: UiText? = null,
val lastSyncAt: Long = 0, // 最近一次成功同步的 epoch ms(0 = 从未同步)
/** 距上次成功同步已超过窗口守护阈值:数据可能过期,UI 给出提示 */
val syncStale: Boolean = false,
val timeFilter: TimeFilter = TimeFilter.All,
val dailyPage: Int = 1,
val modelFilter: String? = null // null = 全部模型

View File

@ -428,6 +428,40 @@ private fun OpenCodeGoForm(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
// ── API Key(查余额,必填)──
Text(
text = stringResource(R.string.opencode_api_key_title),
style = MaterialTheme.typography.titleSmall
)
Text(
text = stringResource(R.string.hint_opencode_trigger_key),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
OutlinedTextField(
value = triggerApiKey,
onValueChange = onApiKeyChange,
label = { Text(stringResource(R.string.field_api_key)) },
placeholder = { Text("opencode-xxx") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth()
)
OutlinedButton(
onClick = onSaveApiKey,
modifier = Modifier.fillMaxWidth(),
enabled = triggerApiKey.isNotBlank()
) {
Text(stringResource(R.string.action_save_api_key))
}
// ── 用量记录 + 模型级明细(选填):auth cookie + workspaceId ──
androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = stringResource(R.string.opencode_session_optional_title),
style = MaterialTheme.typography.titleSmall
)
OutlinedButton(onClick = onImportFromClipboard, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.action_import_from_clipboard))
}
@ -459,7 +493,7 @@ private fun OpenCodeGoForm(
OutlinedButton(
onClick = onTestAndSave,
modifier = Modifier.fillMaxWidth(),
enabled = authCookie.isNotBlank() && workspaceId.isNotBlank()
enabled = triggerApiKey.isNotBlank() && (authCookie.isBlank() == workspaceId.isBlank())
) {
Text(text = stringResource(R.string.action_test_and_save))
}
@ -475,35 +509,6 @@ private fun OpenCodeGoForm(
TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.action_copy_login_url))
}
// ── 一键激活用量 API Key ──
androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = stringResource(R.string.activate_api_key_optional),
style = MaterialTheme.typography.titleSmall
)
Text(
text = stringResource(R.string.hint_opencode_trigger_key),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
OutlinedTextField(
value = triggerApiKey,
onValueChange = onApiKeyChange,
label = { Text(stringResource(R.string.field_api_key)) },
placeholder = { Text("opencode-xxx") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth()
)
OutlinedButton(
onClick = onSaveApiKey,
modifier = Modifier.fillMaxWidth(),
enabled = triggerApiKey.isNotBlank()
) {
Text(stringResource(R.string.action_save_api_key))
}
}
@Composable

View File

@ -270,15 +270,22 @@ class CredentialEditViewModel @Inject constructor(
fun testAndSaveOpenCodeGo() {
val type = serviceType ?: return
val current = _uiState.value
if (current.authCookie.isBlank() || current.workspaceId.isBlank()) {
_uiState.update {
it.copy(message = UiText.Resource(R.string.error_auth_cookie_workspace))
}
// 余额只认 API Key:必填;cookie 对可选,但只填一半视为填写错误
if (current.triggerApiKey.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return
}
if (current.authCookie.isBlank() != current.workspaceId.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_auth_cookie_workspace)) }
return
}
viewModelScope.launch {
val previous = credentialRepository.get(type)
doSaveOpenCodeGo(current.workspaceId.trim(), current.authCookie.trim())
doSaveOpenCodeGo(
workspaceId = current.workspaceId.trim().ifBlank { null },
authCookie = current.authCookie.trim().ifBlank { null },
apiKey = current.triggerApiKey.trim()
)
testAndRollback(
type = type,
saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } },
@ -288,15 +295,20 @@ class CredentialEditViewModel @Inject constructor(
}
}
private suspend fun doSaveOpenCodeGo(workspaceId: String, authCookie: String) {
/**
* 保存 OpenCode Go 凭据。参数为 `null` 表示该项不修改(保留已存值),
* 便于"仅更新 API Key"时不冲掉用户已配置的 cookie 对。
*/
private suspend fun doSaveOpenCodeGo(workspaceId: String?, authCookie: String?, apiKey: String? = null) {
val type = serviceType ?: return
val existing = credentialRepository.get(type) as? Credential.SessionCredential
val updated = (existing ?: Credential.SessionCredential(
service = type,
cookies = emptyList()
)).copy(
authCookie = authCookie,
workspaceId = workspaceId,
authCookie = authCookie ?: existing?.authCookie,
workspaceId = workspaceId ?: existing?.workspaceId,
apiKey = apiKey ?: existing?.apiKey,
lastVerifiedAt = System.currentTimeMillis()
)
credentialRepository.save(updated)

View File

@ -28,6 +28,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@ -146,6 +148,12 @@ fun SettingsScreen(
item {
LanguageCard(onClick = { showLanguageDialog = true })
}
item {
BackgroundSyncCard(
enabled = uiState.backgroundSyncEnabled,
onToggle = { viewModel.setBackgroundSyncEnabled(it) }
)
}
item {
TipsCard(onClick = { onOpenTips() })
}
@ -209,6 +217,51 @@ private fun LanguageCard(onClick: () -> Unit) {
}
}
/**
* 后台同步开关卡片。
*
* 后台任务由 WorkManager 每 8 小时执行一次(不保证准时,系统省电策略可能使其延后),
* 关闭后仍有「进入页面自动同步」兜底,只是丢了「忘记打开 app」这层保险。
*/
@Composable
private fun BackgroundSyncCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🔄",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.settings_background_sync_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.settings_background_sync_desc),
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
Switch(
checked = enabled,
onCheckedChange = onToggle,
colors = SwitchDefaults.colors(checkedTrackColor = StrawberryPink)
)
}
}
}
@Composable
private fun LanguageDialog(onDismiss: () -> Unit) {
val context = LocalContext.current

View File

@ -1,10 +1,14 @@
package com.rainy.token.ui.settings
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.usecase.UsageSyncCoordinator
import com.rainy.token.sync.UsageSyncScheduler
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -13,12 +17,13 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* 设置页 ViewModel。当前阶段只展示凭据状态列表,后续阶段会加入
* 刷新间隔、预警阈值、深色模式等设置项。
* 设置页 ViewModel:凭据状态列表 + 用量同步相关开关。
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val credentialRepository: CredentialRepository
private val credentialRepository: CredentialRepository,
private val syncCoordinator: UsageSyncCoordinator,
@ApplicationContext private val appContext: Context
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
@ -33,13 +38,29 @@ class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(loading = true) }
val statuses = credentialRepository.statusForAll()
_uiState.update {
it.copy(loading = false, credentialStatuses = statuses)
it.copy(
loading = false,
credentialStatuses = statuses,
backgroundSyncEnabled = syncCoordinator.backgroundSyncEnabled
)
}
}
}
/**
* 后台同步开关:写入偏好并立刻注册 / 取消 8 小时周期任务。
*
* 关闭后前台(进入页面)同步仍然生效,只是不再有后台兜底。
*/
fun setBackgroundSyncEnabled(enabled: Boolean) {
syncCoordinator.backgroundSyncEnabled = enabled
UsageSyncScheduler.apply(appContext, enabled)
_uiState.update { it.copy(backgroundSyncEnabled = enabled) }
}
}
data class SettingsUiState(
val loading: Boolean = false,
val credentialStatuses: List<CredentialStatus> = emptyList()
val credentialStatuses: List<CredentialStatus> = emptyList(),
val backgroundSyncEnabled: Boolean = true
)

View File

@ -141,7 +141,7 @@
<string name="usage_cache_write">缓存写入 %1$s</string>
<string name="usage_no_data_sync">暂无数据,点击 🔄 同步</string>
<string name="usage_new_records">✓ 新增 %1$d 条</string>
<string name="sync_partial">部分同步完成:插入 %1$d条,%2$d页失败</string>
<string name="sync_partial">部分同步:新增 %1$d 条,%2$d 项异常,可再次同步</string>
<string name="usage_click_to_switch"> 点击切换</string>
<string name="usage_utc0_note">所有时间按 UTC+0 计算</string>
<string name="msg_utc8_on">已切换到 UTC+8,图表按北京时间显示</string>
@ -199,7 +199,7 @@
<string name="help_cookie_3">3. 登录后按 F12 → Application → Cookies</string>
<string name="help_cookie_4">4. 复制该站点下的所有 Cookie 粘贴到上面输入框</string>
<string name="help_cookie_5">格式:name1=value1; name2=value2</string>
<string name="help_ocgo_title">如何获取 OpenCode Go 凭据?</string>
<string name="help_ocgo_title">如何获取 auth cookie 与 workspaceId?</string>
<string name="help_ocgo_1">1. 桌面 Chrome 打开 opencode.ai/auth</string>
<string name="help_ocgo_2">2. 用 GitHub / Google 登录(Google 不会拦截 Chrome)</string>
<string name="help_ocgo_3">3. 登录后跳转到 dashboard,URL 形如:</string>
@ -347,12 +347,12 @@
<string name="chart_detail_cost">消费明细</string>
<string name="chart_detail_requests">调用次数明细</string>
<string name="chart_detail_tokens">Token 明细</string>
<string name="dialog_clear_title">清除数据</string>
<string name="dialog_clear_body">此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。</string>
<string name="dialog_clear_confirm_question">是否继续?</string>
<string name="action_confirm_countdown">确认(%1$ds)</string>
<string name="action_confirm_clear">确认清除</string>
<string name="action_close">关闭</string>
<!-- ══════════ 同步与后台同步 ══════════ -->
<string name="usage_sync_stale">数据可能已过期,建议立即同步</string>
<string name="settings_background_sync_title">后台同步</string>
<string name="settings_background_sync_desc">有网络时每 8 小时自动同步一次 CommandCode 用量;系统省电策略可能使其延后。</string>
<string name="range_from">从 %1$s</string>
<string name="range_to">至 %1$s</string>
<string name="action_apply_custom_range">应用自定义范围</string>
@ -400,14 +400,16 @@
<string name="placeholder_ollama_api_key">ollama-xxx 或 sk-xxx</string>
<string name="action_save_api_key">保存 API Key</string>
<string name="credential_title_opencode_go">OpenCode Go 凭据</string>
<string name="credential_hint_opencode_go">需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。</string>
<string name="credential_hint_opencode_go">余额与 5h/周/月 三档用量通过 API Key 查询(opencode.ai/settings → API Keys 获取)。下方 auth cookie + workspaceId 为选填,仅用于用量记录页与模型级明细。</string>
<string name="action_import_from_clipboard">从剪贴板自动导入</string>
<string name="placeholder_workspace_id">dashboard URL 中的 {id} 段</string>
<string name="field_auth_cookie_value">auth Cookie 值</string>
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → auth 的 Value</string>
<string name="action_how_get_two_values">如何获取这两个值?</string>
<string name="action_open_login_entry">在浏览器中打开登录入口</string>
<string name="hint_opencode_trigger_key">填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。</string>
<string name="hint_opencode_trigger_key">API Key 用于查询余额,也可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。</string>
<string name="opencode_api_key_title">API Key(查询余额)</string>
<string name="opencode_session_optional_title">用量记录与模型级明细(选填)</string>
<string name="credential_title_ccgo">CommandCode 凭据</string>
<string name="credential_hint_ccgo">需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。</string>
<string name="placeholder_api_key_from_settings">从 Settings → API Keys 生成</string>

View File

@ -141,7 +141,7 @@
<string name="usage_cache_write">快取寫入 %1$s</string>
<string name="usage_no_data_sync">暫無數據,點選 🔄 同步</string>
<string name="usage_new_records">✓ 新增 %1$d 條</string>
<string name="sync_partial">部分同步完成:插入 %1$d條,%2$d頁失敗</string>
<string name="sync_partial">部分同步:新增 %1$d 條,%2$d 項異常,可再次同步</string>
<string name="usage_click_to_switch"> 點選切換</string>
<string name="usage_utc0_note">所有時間按 UTC+0 計算</string>
<string name="msg_utc8_on">已切換到 UTC+8,圖表按北京時間顯示</string>
@ -199,7 +199,7 @@
<string name="help_cookie_3">3. 登入後按 F12 → Application → Cookies</string>
<string name="help_cookie_4">4. 複製該站點下的所有 Cookie 貼上到上面輸入框</string>
<string name="help_cookie_5">格式:name1=value1; name2=value2</string>
<string name="help_ocgo_title">如何獲取 OpenCode Go 憑據?</string>
<string name="help_ocgo_title">如何獲取 auth cookie 與 workspaceId?</string>
<string name="help_ocgo_1">1. 桌面 Chrome 開啟 opencode.ai/auth</string>
<string name="help_ocgo_2">2. 用 GitHub / Google 登入(Google 不會攔截 Chrome)</string>
<string name="help_ocgo_3">3. 登入後跳轉到 dashboard,URL 形如:</string>
@ -347,11 +347,10 @@
<string name="chart_detail_cost">消費明細</string>
<string name="chart_detail_requests">呼叫次數明細</string>
<string name="chart_detail_tokens">Token 明細</string>
<string name="dialog_clear_title">清除數據</string>
<string name="dialog_clear_body">此操作將清除所有 CommandCode 用量本地快取並重新同步,需要幾秒鐘完成。</string>
<string name="dialog_clear_confirm_question">是否繼續?</string>
<string name="action_confirm_countdown">確認(%1$ds)</string>
<string name="action_confirm_clear">確認清除</string>
<!-- ══════════ 同步與後台同步 ══════════ -->
<string name="usage_sync_stale">資料可能已過期,建議立即同步</string>
<string name="settings_background_sync_title">後台同步</string>
<string name="settings_background_sync_desc">有網路時每 8 小時自動同步一次 CommandCode 用量;系統省電策略可能使其延後。</string>
<string name="action_close">關閉</string>
<string name="range_from">從 %1$s</string>
<string name="range_to">至 %1$s</string>
@ -400,14 +399,16 @@
<string name="placeholder_ollama_api_key">ollama-xxx 或 sk-xxx</string>
<string name="action_save_api_key">儲存 API Key</string>
<string name="credential_title_opencode_go">OpenCode Go 憑據</string>
<string name="credential_hint_opencode_go">需要 auth cookie + workspaceId 兩個值。APP 會用 OkHttp 抓 dashboard 並自動解析 5h/周/月 三檔用量。</string>
<string name="credential_hint_opencode_go">餘額與 5h/週/月 三檔用量透過 API Key 查詢(opencode.ai/settings → API Keys 獲取)。下方 auth cookie + workspaceId 為選填,僅用於用量記錄頁與模型級明細。</string>
<string name="action_import_from_clipboard">從剪貼簿自動匯入</string>
<string name="placeholder_workspace_id">dashboard URL 中的 {id} 段</string>
<string name="field_auth_cookie_value">auth Cookie 值</string>
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → auth 的 Value</string>
<string name="action_how_get_two_values">如何獲取這兩個值?</string>
<string name="action_open_login_entry">在瀏覽器中開啟登入入口</string>
<string name="hint_opencode_trigger_key">填入 OpenCode Zen API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 opencode.ai/settings → API Keys 獲取。</string>
<string name="hint_opencode_trigger_key">API Key 用於查詢餘額,也可在詳情頁一鍵傳送請求啟用用量統計。從 opencode.ai/settings → API Keys 獲取。</string>
<string name="opencode_api_key_title">API Key(查詢餘額)</string>
<string name="opencode_session_optional_title">用量記錄與模型級明細(選填)</string>
<string name="credential_title_ccgo">CommandCode 憑據</string>
<string name="credential_hint_ccgo">需要 API Key(拉餘額)。Session Cookie(可選)在瀏覽器 DevTools 複製,用於拉用量記錄。不填也可正常檢視餘額。</string>
<string name="placeholder_api_key_from_settings">從 Settings → API Keys 生成</string>

View File

@ -144,7 +144,7 @@
<string name="usage_cache_write">Cache write %1$s</string>
<string name="usage_no_data_sync">No data yet · tap 🔄 to sync</string>
<string name="usage_new_records">✓ %1$d new records</string>
<string name="sync_partial">Partial sync: %1$d records inserted, %2$d pages failed</string>
<string name="sync_partial">Partial sync: %1$d new, %2$d issue(s) — try again</string>
<string name="usage_click_to_switch"> tap to switch</string>
<string name="usage_utc0_note">All times in UTC+0</string>
<string name="msg_utc8_on">Switched to UTC+8, charts shown in Beijing time</string>
@ -202,7 +202,7 @@
<string name="help_cookie_3">3. After login, press F12 → Application → Cookies</string>
<string name="help_cookie_4">4. Copy all cookies of the site and paste them above</string>
<string name="help_cookie_5">Format: name1=value1; name2=value2</string>
<string name="help_ocgo_title">How to get OpenCode Go credentials?</string>
<string name="help_ocgo_title">How to get the auth cookie and workspaceId?</string>
<string name="help_ocgo_1">1. Open opencode.ai/auth in desktop Chrome</string>
<string name="help_ocgo_2">2. Sign in with GitHub / Google (Google won\'t block Chrome)</string>
<string name="help_ocgo_3">3. You\'ll land on the dashboard, URL looks like:</string>
@ -350,12 +350,12 @@
<string name="chart_detail_cost">Spending breakdown</string>
<string name="chart_detail_requests">Request breakdown</string>
<string name="chart_detail_tokens">Token breakdown</string>
<string name="dialog_clear_title">Clear data</string>
<string name="dialog_clear_body">This clears all cached CommandCode usage and re-syncs. It takes a few seconds.</string>
<string name="dialog_clear_confirm_question">Continue?</string>
<string name="action_confirm_countdown">Confirm (%1$ds)</string>
<string name="action_confirm_clear">Yes, clear</string>
<string name="action_close">Close</string>
<!-- ══════════ 同步与后台同步 ══════════ -->
<string name="usage_sync_stale">Data may be out of date — tap sync now</string>
<string name="settings_background_sync_title">Background sync</string>
<string name="settings_background_sync_desc">Sync CommandCode usage every 8 hours when network is available. System power saving may delay it.</string>
<string name="range_from">From %1$s</string>
<string name="range_to">To %1$s</string>
<string name="action_apply_custom_range">Apply custom range</string>
@ -403,14 +403,16 @@
<string name="placeholder_ollama_api_key">ollama-xxx or sk-xxx</string>
<string name="action_save_api_key">Save API Key</string>
<string name="credential_title_opencode_go">OpenCode Go credentials</string>
<string name="credential_hint_opencode_go">Needs both auth cookie and workspaceId. The app fetches the dashboard via OkHttp and parses 5h/week/month usage.</string>
<string name="credential_hint_opencode_go">Balance and 5h/week/month usage are fetched with an API Key (get it at opencode.ai/settings → API Keys). The auth cookie and workspaceId below are optional — only needed for usage records and the per-model breakdown.</string>
<string name="action_import_from_clipboard">Auto-import from clipboard</string>
<string name="placeholder_workspace_id">The {id} segment of the dashboard URL</string>
<string name="field_auth_cookie_value">auth Cookie value</string>
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → Value of auth</string>
<string name="action_how_get_two_values">How to get these two?</string>
<string name="action_open_login_entry">Open login page in browser</string>
<string name="hint_opencode_trigger_key">With an OpenCode Zen API Key, the details page can send one request to activate usage stats. Get it at opencode.ai/settings → API Keys.</string>
<string name="hint_opencode_trigger_key">The API Key fetches your balance; the details page can also send one request to activate usage stats. Get it at opencode.ai/settings → API Keys.</string>
<string name="opencode_api_key_title">API Key (fetches balance)</string>
<string name="opencode_session_optional_title">Usage records and per-model breakdown (optional)</string>
<string name="credential_title_ccgo">CommandCode credentials</string>
<string name="credential_hint_ccgo">Needs an API Key (for balance). Session Cookie (optional) for usage records, copied from browser DevTools. Balance works without it.</string>
<string name="placeholder_api_key_from_settings">Generate at Settings → API Keys</string>

View File

@ -143,6 +143,27 @@ class CommandCodeUsageRepositoryTest {
assertTrue(decoded.contains("\"id\":\"rec-${CommandCodeUsageRepository.PAGE_SIZE - 1}\""))
}
@Test
fun `full page with one unparsable record still yields next cursor`() {
// 回归:旧实现用「解析后条数 < PAGE_SIZE」判定到底,
// 一条记录缺少 createdAt 就会让整次同步被当成已完成,本地永久留下空洞。
val body = buildString {
append("""{"usages":[""")
for (i in 0 until CommandCodeUsageRepository.PAGE_SIZE - 1) {
if (i > 0) append(",")
append(usageJson("rec-$i", 100L + i, 10L, 0.001))
}
append(",{\"id\":\"broken-1\",\"tokensIn\":\"1\",\"tokensOut\":\"1\"}") // 缺 createdAt → 该条解析失败
append("]}")
}
val page = CommandCodeUsageRepository.parseUsageResponse(body)
assertEquals(CommandCodeUsageRepository.PAGE_SIZE - 1, page.records.size)
assertEquals(CommandCodeUsageRepository.PAGE_SIZE, page.rawCount)
assertEquals(1, page.droppedCount)
// 满页 → 必须继续翻页,而不是判定「到底」
assertNotNull(page.nextCursor)
}
@Test
fun `legacy format stays compatible`() {
val body = """

View File

@ -5,233 +5,127 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
/**
* Unit tests for [OpenCodeGoRepository.parseWindows].
* Unit tests for [OpenCodeGoRepository.parseUsageResponse] / [OpenCodeGoRepository.parseModelRows] /
* [OpenCodeGoRepository.windowQuotaRaw].
*
* Uses synthetic HTML snippets that mimic the SolidJS SSR hydration data format.
* All tests are pure JVM — no Android framework, no network.
*/
class OpenCodeGoRepositoryTest {
/**
* Build a minimal hydration HTML with the given window data.
*
* The real format looks like:
* rollingUsage:$R[0]={status:"ok",usagePercent:42,resetInSec:12345}
*
* @param usage 可选:页面新增的用量字段(usage)
* @param limit 可选:页面新增的限额字段(limit)
*/
private fun buildHtml(
rolling: Pair<Int, Long>? = Pair(42, 3600),
weekly: Pair<Int, Long>? = Pair(15, 86400),
monthly: Pair<Int, Long>? = Pair(80, 2592000),
usage: Long? = null,
limit: Long? = null
/** 固定"现在",保证倒计时断言稳定。 */
private val now = 1_700_000_000_000L
/** 构造 /zen/go/v1/usage 响应;参数为 null 时该窗口不出现。 */
private fun usageJson(
rolling: String? = """{"status":"ok","percent":42,"resetsAt":"${Instant.ofEpochMilli(now + 3_600_000)}"}""",
weekly: String? = """{"status":"ok","percent":15,"resetsAt":"${Instant.ofEpochMilli(now + 86_400_000)}"}""",
monthly: String? = """{"status":"ok","percent":80,"resetsAt":"${Instant.ofEpochMilli(now + 2_592_000_000)}"}"""
): String {
fun windowJs(name: String, data: Pair<Int, Long>?): String {
if (data == null) return ""
val extra = if (usage != null && limit != null) ",usage:$usage,limit:$limit" else ""
return """$name:${'$'}R[0]={status:"ok",usagePercent:${data.first},resetInSec:${data.second}$extra}"""
}
return """
<html>
<body>
<script>
${windowJs("rollingUsage", rolling)}
${windowJs("weeklyUsage", weekly)}
${windowJs("monthlyUsage", monthly)}
</script>
</body>
</html>
""".trimIndent()
val parts = listOfNotNull(
rolling?.let { """"rolling":$it""" },
weekly?.let { """"weekly":$it""" },
monthly?.let { """"monthly":$it""" }
)
return """{"usage":{${parts.joinToString(",")}}}"""
}
// ─────────────── parseUsageResponse(余额 API JSON)───────────────
@Test
fun `parses all three windows correctly`() {
val html = buildHtml(
rolling = Pair(42, 3600),
weekly = Pair(15, 86400),
monthly = Pair(80, 2592000)
)
val result = OpenCodeGoRepository.parseWindows(html)
fun `parses all three windows from usage api`() {
val result = OpenCodeGoRepository.parseUsageResponse(usageJson(), now)
assertEquals(3, result.size)
assertNotNull(result["rollingUsage"])
assertEquals(42f, result["rollingUsage"]!!.usagePercent)
assertEquals(3600L, result["rollingUsage"]!!.resetInSec)
assertNotNull(result["weeklyUsage"])
assertEquals(15f, result["weeklyUsage"]!!.usagePercent)
assertEquals(86400L, result["weeklyUsage"]!!.resetInSec)
assertNotNull(result["monthlyUsage"])
assertEquals(80f, result["monthlyUsage"]!!.usagePercent)
assertEquals(2592000L, result["monthlyUsage"]!!.resetInSec)
}
@Test
fun `parses rolling usage only`() {
val html = buildHtml(weekly = null, monthly = null)
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(1, result.size)
assertNotNull(result["rollingUsage"])
assertEquals(42f, result["rollingUsage"]!!.usagePercent)
}
@Test
fun `returns empty map for HTML without hydration data`() {
val html = "<html><body><h1>Welcome</h1></body></html>"
val result = OpenCodeGoRepository.parseWindows(html)
assertTrue(result.isEmpty())
}
@Test
fun `returns empty map for empty string`() {
val result = OpenCodeGoRepository.parseWindows("")
assertTrue(result.isEmpty())
}
@Test
fun `handles nested objects in hydration data`() {
val html = """
<html><body><script>
rollingUsage:${'$'}R[0]={status:"ok",metadata:{version:2},usagePercent:42,resetInSec:3600}
</script></body></html>
""".trimIndent()
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(1, result.size)
assertNotNull(result["rollingUsage"])
assertEquals(42f, result["rollingUsage"]!!.usagePercent)
assertEquals(3600L, result["rollingUsage"]!!.resetInSec)
}
@Test
fun `skips window when usagePercent is missing`() {
val html = """
<html><body><script>
rollingUsage:${'$'}R[0]={status:"ok",resetInSec:3600}
</script></body></html>
""".trimIndent()
val result = OpenCodeGoRepository.parseWindows(html)
assertTrue(result.isEmpty())
}
@Test
fun `skips window when resetInSec is missing`() {
val html = """
<html><body><script>
rollingUsage:${'$'}R[0]={status:"ok",usagePercent:42}
</script></body></html>
""".trimIndent()
val result = OpenCodeGoRepository.parseWindows(html)
assertTrue(result.isEmpty())
}
@Test
fun `handles zero percent and reset`() {
val html = buildHtml(
rolling = Pair(0, 0),
weekly = null,
monthly = null
)
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(1, result.size)
assertEquals(0f, result["rollingUsage"]!!.usagePercent)
assertEquals(0L, result["rollingUsage"]!!.resetInSec)
}
@Test
fun `handles large reset values`() {
val html = buildHtml(
rolling = Pair(99, 9999999999L),
weekly = null,
monthly = null
)
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(1, result.size)
assertEquals(99f, result["rollingUsage"]!!.usagePercent)
assertEquals(9999999999L, result["rollingUsage"]!!.resetInSec)
}
@Test
fun `does not match field without dollar R prefix`() {
val html = """
<html><body><script>
rollingUsage:{usagePercent:42,resetInSec:3600}
</script></body></html>
""".trimIndent()
val result = OpenCodeGoRepository.parseWindows(html)
// Should not match because the prefix "rollingUsage:$R[" is missing
assertTrue(result.isEmpty())
}
@Test
fun `parses new page format with decimal percent and usage fields`() {
// 新页面(2026-08)实测格式:usagePercent 为小数,并新增 usage/limit 字段
val html = """
<html><body><script>
rollingUsage:${'$'}R[37]={status:"ok",resetInSec:1406,usagePercent:1.1,usage:13314666,limit:1200000000}
weeklyUsage:${'$'}R[38]={status:"ok",resetInSec:339417,usagePercent:2.5,usage:74497156,limit:3000000000}
monthlyUsage:${'$'}R[39]={status:"ok",resetInSec:1229614,usagePercent:7.2,usage:434167747,limit:6000000000}
</script></body></html>
""".trimIndent()
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(3, result.size)
val rolling = result["rollingUsage"]!!
assertEquals(1.1f, rolling.usagePercent)
assertEquals(1406L, rolling.resetInSec)
assertEquals(13314666L, rolling.usage)
assertEquals(1200000000L, rolling.limit)
assertEquals(42f, rolling.usagePercent)
assertEquals(3600L, rolling.resetInSec)
assertEquals(now + 3_600_000L, rolling.resetAtMillis)
val weekly = result["weeklyUsage"]!!
assertEquals(2.5f, weekly.usagePercent)
assertEquals(339417L, weekly.resetInSec)
assertEquals(74497156L, weekly.usage)
assertEquals(3000000000L, weekly.limit)
assertEquals(15f, result["weeklyUsage"]!!.usagePercent)
assertEquals(86_400L, result["weeklyUsage"]!!.resetInSec)
assertEquals(80f, result["monthlyUsage"]!!.usagePercent)
assertEquals(2_592_000L, result["monthlyUsage"]!!.resetInSec)
}
val monthly = result["monthlyUsage"]!!
assertEquals(7.2f, monthly.usagePercent)
assertEquals(1229614L, monthly.resetInSec)
assertEquals(434167747L, monthly.usage)
assertEquals(6000000000L, monthly.limit)
@Test
fun `parses rolling window only`() {
val result = OpenCodeGoRepository.parseUsageResponse(usageJson(weekly = null, monthly = null), now)
assertEquals(1, result.size)
assertNotNull(result["rollingUsage"])
assertNull(result["weeklyUsage"])
}
@Test
fun `keeps window when resetsAt is missing`() {
// resetsAt 可选:窗口仍展示百分比,只是没有倒计时
val json = """{"usage":{"rolling":{"status":"ok","percent":42}}}"""
val result = OpenCodeGoRepository.parseUsageResponse(json, now)
assertEquals(1, result.size)
val rolling = result["rollingUsage"]!!
assertEquals(42f, rolling.usagePercent)
assertNull(rolling.resetInSec)
assertNull(rolling.resetAtMillis)
}
@Test
fun `skips window when percent is missing or invalid`() {
val json = """{"usage":{"rolling":{"status":"ok","resetsAt":"2026-08-12T20:00:00Z"},"weekly":{"percent":"abc"}}}"""
val result = OpenCodeGoRepository.parseUsageResponse(json, now)
assertTrue(result.isEmpty())
}
@Test
fun `parses epoch seconds and epoch millis resetsAt`() {
val seconds = (now + 3_600_000L) / 1000
val viaSeconds = OpenCodeGoRepository.parseUsageResponse(
"""{"usage":{"rolling":{"percent":10,"resetsAt":$seconds}}}""", now
)
assertEquals(3600L, viaSeconds["rollingUsage"]!!.resetInSec)
val viaMillis = OpenCodeGoRepository.parseUsageResponse(
"""{"usage":{"rolling":{"percent":10,"resetsAt":${now + 3_600_000L}}}}""", now
)
assertEquals(3600L, viaMillis["rollingUsage"]!!.resetInSec)
}
@Test
fun `clamps percent into valid range`() {
val over = OpenCodeGoRepository.parseUsageResponse(
"""{"usage":{"rolling":{"percent":150}}}""", now
)
assertEquals(100f, over["rollingUsage"]!!.usagePercent)
val under = OpenCodeGoRepository.parseUsageResponse(
"""{"usage":{"rolling":{"percent":-5}}}""", now
)
assertEquals(0f, under["rollingUsage"]!!.usagePercent)
}
@Test
fun `keeps decimal percent without rounding to int`() {
// 回归保护:usagePercent 为小数时不能被 toIntOrNull 丢掉
val html = """
<html><body><script>
rollingUsage:${'$'}R[0]={status:"ok",resetInSec:60,usagePercent:0.5}
</script></body></html>
""".trimIndent()
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(1, result.size)
assertEquals(0.5f, result["rollingUsage"]!!.usagePercent)
val result = OpenCodeGoRepository.parseUsageResponse(
"""{"usage":{"rolling":{"percent":1.1,"resetsAt":"${Instant.ofEpochMilli(now + 60_000)}"}}}""", now
)
assertEquals(1.1f, result["rollingUsage"]!!.usagePercent)
assertEquals(60L, result["rollingUsage"]!!.resetInSec)
}
@Test
fun `tolerates missing usage and limit fields`() {
// 旧页面/旧缓存格式:无 usage/limit,不应影响窗口解析
val html = buildHtml(
rolling = Pair(42, 3600),
weekly = null,
monthly = null
fun `coerces past reset time to zero`() {
val result = OpenCodeGoRepository.parseUsageResponse(
"""{"usage":{"rolling":{"percent":5,"resetsAt":"${Instant.ofEpochMilli(now - 60_000)}"}}}""", now
)
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(0L, result["rollingUsage"]!!.resetInSec)
}
assertEquals(1, result.size)
assertEquals(42f, result["rollingUsage"]!!.usagePercent)
assertNull(result["rollingUsage"]!!.usage)
assertNull(result["rollingUsage"]!!.limit)
@Test
fun `returns empty map for empty or invalid json`() {
assertTrue(OpenCodeGoRepository.parseUsageResponse("", now).isEmpty())
assertTrue(OpenCodeGoRepository.parseUsageResponse("<html>login</html>", now).isEmpty())
assertTrue(OpenCodeGoRepository.parseUsageResponse("""{"foo":1}""", now).isEmpty())
}
// ─────────────── parseModelRows(_server 模型级用量)───────────────