Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87c3ea20f4 | ||
|
|
d205ff2ea4 | ||
|
|
67cb816ec7 |
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 选填,仅用于用量记录与模型级明细。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,55 +252,70 @@ 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 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)
|
||||
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
|
||||
}
|
||||
|
||||
/** `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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 openIdx('{' 的位置)开始,用深度计数找匹配的闭合 '}'。
|
||||
* 正确处理嵌套对象:`{status:"ok", sub:{...}, usagePercent:34}`。
|
||||
@ -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)。
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()!!)
|
||||
|
||||
val (records, nextCursor) = pageResult.getOrThrow()
|
||||
if (records.isEmpty()) break
|
||||
if (nextCursor == cursor) break // 游标未前进,防死循环
|
||||
|
||||
// 按 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 (newRecords.isEmpty()) break
|
||||
|
||||
cache.insertAll(newRecords)
|
||||
totalInserted += newRecords.size
|
||||
|
||||
if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break
|
||||
cursor = nextCursor
|
||||
if (++pages >= MAX_PAGES) break // 防御性上限
|
||||
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
|
||||
}
|
||||
|
||||
return Result.success(SyncResult(inserted = totalInserted))
|
||||
val page = pageResult.getOrThrow()
|
||||
pages++
|
||||
scanned += page.rawCount
|
||||
dropped += page.droppedCount
|
||||
|
||||
if (page.records.isEmpty() && page.rawCount > 0) {
|
||||
// 服务端有数据但整页解析失败:按异常处理,不停成「已接到旧数据」
|
||||
stop = SyncStopReason.PARSE_ANOMALY
|
||||
break
|
||||
}
|
||||
|
||||
// 整页命中判定基于服务端原始条数:个别记录解析失败不应让整页被误判为「已存在」
|
||||
val newRecords = page.records.filter { it.id !in existingIds }
|
||||
|
||||
if (page.rawCount > 0 && newRecords.isEmpty()) {
|
||||
// 整页都是已知数据 → 已接到旧数据,停止(保留既有启发式)
|
||||
stop = SyncStopReason.HIT_EXISTING
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -79,12 +79,37 @@ 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 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}页失败")
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
94
app/src/main/java/com/rainy/token/sync/UsageSyncWorker.kt
Normal file
94
app/src/main/java/com/rainy/token/sync/UsageSyncWorker.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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") {
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,清空本地缓存无法补回历史,只会永久丢失现有记录
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,64 +210,79 @@ 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 syncedAt = System.currentTimeMillis()
|
||||
if (result.isSuccess && wid != null) 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 && wid != null) syncedAt 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() }
|
||||
if (cache.count() == 0) useCase.fullSync() else useCase.incrementalSync()
|
||||
}
|
||||
}
|
||||
|
||||
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) syncedAt else it.lastSyncAt
|
||||
lastSyncResult = syncInsertedCount(result),
|
||||
lastSyncError = error?.let { syncErrorToUiText(it) },
|
||||
lastSyncAt = if (result.isSuccess && wid != null) syncCoordinator.lastSyncAt(wid)
|
||||
else it.lastSyncAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateData() {
|
||||
_uiState.value = UsageUiState()
|
||||
/**
|
||||
* 进入 / 回到前台时的自动同步(主力防线)。
|
||||
*
|
||||
* 距上次成功同步超过 [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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步结果里的新增条数:成功取 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,
|
||||
@ -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 = 全部模型
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = """
|
||||
|
||||
@ -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}"""
|
||||
val parts = listOfNotNull(
|
||||
rolling?.let { """"rolling":$it""" },
|
||||
weekly?.let { """"weekly":$it""" },
|
||||
monthly?.let { """"monthly":$it""" }
|
||||
)
|
||||
return """{"usage":{${parts.joinToString(",")}}}"""
|
||||
}
|
||||
|
||||
return """
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
${windowJs("rollingUsage", rolling)}
|
||||
${windowJs("weeklyUsage", weekly)}
|
||||
${windowJs("monthlyUsage", monthly)}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
}
|
||||
// ─────────────── 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 模型级用量)───────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user