From ef568d9892c370cdc4020c191c4a245c5941eee9 Mon Sep 17 00:00:00 2001 From: zergfff Date: Tue, 18 Aug 2026 16:30:12 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(ccgo):=20=E6=98=BE=E7=A4=BA=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=9C=88=E6=B6=88=E8=80=97=E9=87=8F=EF=BC=88alpha/usa?= =?UTF-8?q?ge/summary=EF=BC=89+=20GOAT=20=E8=AE=A1=E5=88=92=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 alpha/usage/summary 端点拉取账单周期真实月消耗(网页版 Monthly Usage 同源) - PLANS 表补 individual-goat(0) 并修正 individual-pro(0) 等官方值 - 详情页本月窗口优先用 summary 真实已用,修复月重置倒计时 - windowLimits.limited 改用 booleanOrNull 安全解析 --- .../repository/CommandCodeGoRepository.kt | 78 ++++++++++++++++--- .../ui/servicedetail/ServiceDetailScreen.kt | 25 +++--- 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt index c9461af..e9279e3 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt @@ -11,6 +11,8 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.long import kotlinx.serialization.json.double import okhttp3.OkHttpClient @@ -28,6 +30,10 @@ import javax.inject.Singleton * 调 subscription 端点获取计划信息(用来算已用/总量百分比): * GET https://api.commandcode.ai/alpha/billing/subscriptions * Authorization: Bearer + * + * 调 usage summary 端点获取账单周期内真实月消耗(网页版 Monthly Usage 数据源): + * GET https://api.commandcode.ai/alpha/usage/summary + * Authorization: Bearer */ @Singleton class CommandCodeGoRepository( @@ -52,9 +58,10 @@ class CommandCodeGoRepository( return@withContext Result.failure(RepositoryError.InvalidCredential()) } - // 顺序拉取 credits + subscriptions(credits 是主要数据源,subscription 用于补充计划信息) + // 顺序拉取 credits + subscriptions + usage summary val creditsResult = runCatching { fetchCredits(apiKey) } val subResult = runCatching { fetchSubscription(apiKey) } + val summaryResult = runCatching { fetchUsageSummary(apiKey) } val creditsPayload = creditsResult.getOrElse { e -> return@withContext Result.failure( @@ -71,9 +78,12 @@ class CommandCodeGoRepository( val config = ServiceConfigProvider.get(ServiceType.COMMANDCODE_GO) - val used = monthlyTotal?.let { total -> - maxOf(0.0, total - creditsPayload.monthlyCredits) - } + // 优先用 usage/summary 返回的账单周期真实已用(网页版 Monthly Usage 同源); + // 拉取失败时回退到 总量 - 剩余 的反推值(此时可能不完整,但至少能显示) + val used = summaryResult.getOrNull()?.totalMonthlyCredits + ?: monthlyTotal?.let { total -> + maxOf(0.0, total - creditsPayload.monthlyCredits) + } val extras = buildMap { put("monthlyRemaining", creditsPayload.monthlyCredits.toString()) @@ -87,7 +97,7 @@ class CommandCodeGoRepository( creditsPayload.weeklyUsed?.let { put("weekly.used", it.toString()) } creditsPayload.weeklyCap?.let { put("weekly.cap", it.toString()) } creditsPayload.weeklyResetAt?.let { put("weekly.resetInSec", epochToRemainingSec(it).toString()) } - billingPeriodEndMillis?.let { + billingPeriodEndMillis?.let { put("billingPeriodEnd", it.toString()) put("monthly.resetInSec", epochToRemainingSec(it).toString()) } @@ -136,12 +146,12 @@ class CommandCodeGoRepository( obj[key]?.jsonPrimitive?.long val fiveHour = root["windowLimits"]?.jsonObject?.let { limits -> - if (limits["limited"]?.jsonPrimitive?.content == "true") { + if (limits["limited"]?.jsonPrimitive?.booleanOrNull == true) { limits["fiveHour"]?.jsonObject } else null } val weekly = root["windowLimits"]?.jsonObject?.let { limits -> - if (limits["limited"]?.jsonPrimitive?.content == "true") { + if (limits["limited"]?.jsonPrimitive?.booleanOrNull == true) { limits["weekly"]?.jsonObject } else null } @@ -187,22 +197,62 @@ class CommandCodeGoRepository( } catch (_: Exception) { null } } + /** + * 拉取账单周期内真实月消耗(网页版 Monthly Usage 的数据源)。 + * + * 返回示例(periodBasis=billing-period 表示按订阅账单周期统计): + * { "totalCount":226, "totalCredits":3.1711, "totalMonthlyCredits":3.1711, + * "totalPurchasedCredits":0, "totalFreeCredits":0, "periodBasis":"billing-period" } + */ + private fun fetchUsageSummary(apiKey: String): UsageSummaryPayload? { + val request = Request.Builder() + .url("$apiBase/alpha/usage/summary") + .header("Authorization", "Bearer $apiKey") + .header("Accept", "application/json") + .get() + .build() + + val response = try { + okHttpClient.newCall(request).execute() + } catch (_: Exception) { + return null + } + + if (!response.isSuccessful) return null + val body = response.body?.string() ?: return null + + return try { + val root = json.parseToJsonElement(body).jsonObject + UsageSummaryPayload( + totalMonthlyCredits = root["totalMonthlyCredits"]?.jsonPrimitive?.doubleOrNull, + totalPurchasedCredits = root["totalPurchasedCredits"]?.jsonPrimitive?.doubleOrNull, + totalFreeCredits = root["totalFreeCredits"]?.jsonPrimitive?.doubleOrNull, + periodBasis = root["periodBasis"]?.jsonPrimitive?.contentOrNull + ) + } catch (_: Exception) { null } + } + private fun planDisplayName(planId: String): String = PLAN_NAMES[planId.lowercase()] ?: planId companion object { + /** 官方月度额度(USD credits):https://commandcode.ai/docs/resources/pricing-limits */ private val PLANS = mapOf( "individual-go" to 10.0, - "individual-pro" to 30.0, + "individual-goat" to 70.0, + "individual-pro" to 80.0, "individual-max" to 150.0, - "individual-ultra" to 300.0 + "individual-ultra" to 300.0, + "team-pro" to 40.0 ) private val PLAN_NAMES = mapOf( "individual-go" to "Go", + "individual-goat" to "GOAT", "individual-pro" to "Pro", "individual-max" to "Max", - "individual-ultra" to "Ultra" + "individual-ultra" to "Ultra", + "team-pro" to "Team Pro" ) /** API 返回的是 epoch millis,转为距现在的剩余秒数 */ @@ -235,4 +285,12 @@ class CommandCodeGoRepository( val planId: String, val currentPeriodEnd: String? ) + + /** alpha/usage/summary 响应:账单周期内的真实月消耗。 */ + private data class UsageSummaryPayload( + val totalMonthlyCredits: Double?, + val totalPurchasedCredits: Double?, + val totalFreeCredits: Double?, + val periodBasis: String? + ) } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index b323d67..cd2d5fa 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -248,6 +248,9 @@ private fun CommandCodeGoUsageCard(state: State) { val extras = balance?.extras ?: return val monthlyRemaining = extras["monthlyRemaining"]?.toDoubleOrNull() ?: return val monthlyTotal = extras["monthlyTotal"]?.toDoubleOrNull() + // 真实月消耗(usage/summary);缺失时回退 总量-剩余 + val monthlyUsed = extras["monthlyUsed"]?.toDoubleOrNull() + ?: monthlyTotal?.let { total -> total - monthlyRemaining } val purchased = extras["purchasedCredits"]?.toDoubleOrNull() ?: 0.0 val planName = extras["planName"].orEmpty() @@ -295,14 +298,16 @@ private fun CommandCodeGoUsageCard(state: State) { Spacer(modifier = Modifier.height(14.dp)) } - // 3. 本月(最下面) - if (monthlyTotal != null && monthlyTotal > 0) { - val used = monthlyTotal - monthlyRemaining + // 3. 本月(最下面):已用优先用 summary 真实值 + if (monthlyTotal != null && monthlyTotal > 0 && monthlyUsed != null) { + val used = monthlyUsed.coerceIn(0.0, monthlyTotal) val pct = ((used / monthlyTotal) * 100).toFloat().coerceIn(0f, 100f) UsageWindowRow( label = "本月", pct = pct, - resetInSec = extras["billingPeriodEnd"]?.let { parseIsoDuration(it) } + resetInSec = extras["billingPeriodEnd"]?.toLongOrNull() + ?.let { maxOf(0L, (it - System.currentTimeMillis()) / 1000) } + ?.takeIf { it > 0 } ) Spacer(modifier = Modifier.height(4.dp)) Text( @@ -329,18 +334,6 @@ private fun CommandCodeGoUsageCard(state: State) { } } -/** - * 尝试从 ISO8601 时间戳计算剩余秒数。 - */ -private fun parseIsoDuration(isoStr: String): Long? { - return try { - val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US) - sdf.timeZone = java.util.TimeZone.getTimeZone("UTC") - val end = sdf.parse(isoStr.take(19))?.time ?: return null - maxOf(0L, (end - System.currentTimeMillis()) / 1000) - } catch (_: Exception) { null } -} - /** * OpenCode Go 专属:3 个窗口(rolling 5h / weekly / monthly)独立用量卡。 * From 64ff8feb98e0f64714584f0dbac3f7e17552ec76 Mon Sep 17 00:00:00 2001 From: zergfff Date: Tue, 18 Aug 2026 16:34:47 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(ccgo):=20=E8=A1=A5=20doubleOrNull=20imp?= =?UTF-8?q?ort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rainy/token/data/repository/CommandCodeGoRepository.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt index e9279e3..3c7b368 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt @@ -13,6 +13,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.long import kotlinx.serialization.json.double import okhttp3.OkHttpClient From 6d81894442b24c1baed3e300b0396eb558d78693 Mon Sep 17 00:00:00 2001 From: zergfff Date: Tue, 18 Aug 2026 16:57:59 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(ccgo):=20=E7=94=A8=E9=87=8F=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E8=BF=9B=E5=BA=A6=E6=9D=A1=E6=94=B9=E4=B8=BA=E7=BB=BF?= =?UTF-8?q?/=E6=A9=99/=E7=BA=A2=E4=B8=89=E8=89=B2=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 详情页 UsageWindowRow:进度条+百分比数字 <50% 绿 / 50-80% 橙 / ≥80% 红 - 首页 CompactUsageRow:百分比数字和进度条同步三色 - 首页本月窗口优先读 summary 真实已用(extras.monthlyUsed),回退 monthlySpent --- .../token/ui/dashboard/ServiceBalanceCards.kt | 11 ++++++++--- .../token/ui/servicedetail/ServiceDetailScreen.kt | 15 +++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index b8b69ae..c488b68 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -28,6 +28,7 @@ import com.rainy.token.ui.components.formatAmount import com.rainy.token.ui.components.formatResetInSec import com.rainy.token.ui.components.normalizeWindowLabel import com.rainy.token.ui.theme.StrawberryPink +import com.rainy.token.ui.theme.StatusGreen import com.rainy.token.ui.theme.StatusOrange import com.rainy.token.ui.theme.inkMuted import java.text.SimpleDateFormat @@ -208,7 +209,11 @@ internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) { val windows = listOf( Triple("5 小时", calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()), Triple("本周", calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()), - Triple("本月", calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull()) + // 本月:优先 summary 真实已用,回退 总量-剩余 + Triple("本月", calcPct( + extras["monthlyUsed"]?.toDoubleOrNull() ?: balance.monthlySpent, + balance.totalQuota + ), extras["monthly.resetInSec"]?.toLongOrNull()) ) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { windows.forEach { (label, pct, resetSec) -> @@ -436,7 +441,7 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) { color = when { pct >= 80 -> MaterialTheme.colorScheme.error pct >= 50 -> StatusOrange - else -> MaterialTheme.colorScheme.onSurface + else -> StatusGreen } ) } @@ -450,7 +455,7 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) { color = when { pctValue >= 80f -> MaterialTheme.colorScheme.error pctValue >= 50f -> StatusOrange - else -> StrawberryPink + else -> StatusGreen }, trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeCap = StrokeCap.Butt diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index cd2d5fa..50e6d3d 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -391,6 +391,12 @@ private fun OpenCodeGoWindowsCard(state: State) { @Composable private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decimals: Int = 0) { val pctValue = (pct ?: 0f).coerceIn(0f, 100f) + // 状态语义色:<50% 绿,50-80% 橙,≥80% 红(进度条 + 百分比数字同色) + val statusColor = when { + pctValue >= 80f -> MaterialTheme.colorScheme.error + pctValue >= 50f -> com.rainy.token.ui.theme.StatusOrange + else -> com.rainy.token.ui.theme.StatusGreen + } Column { Row( modifier = Modifier.fillMaxWidth(), @@ -406,7 +412,8 @@ private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decima Text( text = String.format(Locale.US, "%.${decimals}f", pctValue), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + color = statusColor ) Text( text = "%", @@ -423,11 +430,7 @@ private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decima .fillMaxWidth() .height(6.dp) .clip(RoundedCornerShape(3.dp)), - color = when { - pctValue >= 80f -> MaterialTheme.colorScheme.error - pctValue >= 50f -> com.rainy.token.ui.theme.StatusOrange - else -> StrawberryPink - }, + color = statusColor, trackColor = MaterialTheme.colorScheme.surfaceVariant ) if (resetInSec != null && resetInSec > 0) { From af705cc448e1d794ef737793ba405cf271009010 Mon Sep 17 00:00:00 2001 From: zergfff Date: Tue, 18 Aug 2026 19:37:10 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(ccgo):=20=E6=9C=88=E6=B6=88=E8=80=97?= =?UTF-8?q?=E9=87=8F=E8=AF=B7=E6=B1=82=E5=B9=B6=E8=A1=8C=E5=8C=96=20+=20?= =?UTF-8?q?=E7=9F=AD=E8=B6=85=E6=97=B6=EF=BC=8C=E4=BF=AE=E5=A4=8D'?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=B9=B6=E4=BF=9D=E5=AD=98=E6=B2=A1=E5=8F=8D?= =?UTF-8?q?=E5=BA=94'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchBalance 改用 async 并行拉取 credits/subscriptions/summary - fetchUsageSummary 独立 5s 超时(慢/失败返回 null,不影响主流程) - CommandCodeUsageRepository.getCookieHeader 增加 authCookie fallback + 明确错误提示(指导用户粘贴完整 Cookie 字符串) --- .../repository/CommandCodeGoRepository.kt | 26 ++++++++++++++----- .../repository/CommandCodeUsageRepository.kt | 12 ++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt index 3c7b368..710688d 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt @@ -6,6 +6,7 @@ import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -19,6 +20,7 @@ import kotlinx.serialization.json.double import okhttp3.OkHttpClient import okhttp3.Request import java.io.IOException +import java.util.concurrent.TimeUnit import javax.inject.Singleton /** @@ -59,10 +61,16 @@ class CommandCodeGoRepository( return@withContext Result.failure(RepositoryError.InvalidCredential()) } - // 顺序拉取 credits + subscriptions + usage summary - val creditsResult = runCatching { fetchCredits(apiKey) } - val subResult = runCatching { fetchSubscription(apiKey) } - val summaryResult = runCatching { fetchUsageSummary(apiKey) } + // 并行拉取 credits + subscriptions + usage summary。 + // summary 是可选增强(网页版 Monthly Usage 同源),用独立短超时; + // 即使 summary 慢/失败也绝不影响主流程(credits 是主数据源)。 + val creditsDeferred = async { runCatching { fetchCredits(apiKey) } } + val subDeferred = async { runCatching { fetchSubscription(apiKey) } } + val summaryDeferred = async { runCatching { fetchUsageSummaryFast(apiKey) } } + + val creditsResult = creditsDeferred.await() + val subResult = subDeferred.await() + val summaryResult = summaryDeferred.await() val creditsPayload = creditsResult.getOrElse { e -> return@withContext Result.failure( @@ -204,8 +212,14 @@ class CommandCodeGoRepository( * 返回示例(periodBasis=billing-period 表示按订阅账单周期统计): * { "totalCount":226, "totalCredits":3.1711, "totalMonthlyCredits":3.1711, * "totalPurchasedCredits":0, "totalFreeCredits":0, "periodBasis":"billing-period" } + * + * 使用独立短超时(5s),慢/失败直接返回 null,绝不影响主流程。 */ - private fun fetchUsageSummary(apiKey: String): UsageSummaryPayload? { + private fun fetchUsageSummaryFast(apiKey: String): UsageSummaryPayload? { + val client = okHttpClient.newBuilder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() val request = Request.Builder() .url("$apiBase/alpha/usage/summary") .header("Authorization", "Bearer $apiKey") @@ -214,7 +228,7 @@ class CommandCodeGoRepository( .build() val response = try { - okHttpClient.newCall(request).execute() + client.newCall(request).execute() } catch (_: Exception) { return null } diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt index 12ea98b..a380e27 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt @@ -52,12 +52,18 @@ class CommandCodeUsageRepository( if (c !is Credential.SessionCredential) { throw RepositoryError.InvalidCredential() } - // 优先用 cookies 列表,否则尝试从 authCookie 字段解析 + // 优先用 cookies 列表 if (c.cookies.isNotEmpty()) { return c.cookies.joinToString("; ") { "${it.name}=${it.value}" } } - // fallback: 如果用户通过旧版接口存了 authCookie,尝试恢复 - throw RepositoryError.InvalidCredential() + // fallback 1: authCookie 字段(OpenCode Go 遗留格式,可能是 commandcode 会话值) + c.authCookie?.takeIf { it.isNotBlank() }?.let { raw -> + val decoded = runCatching { java.net.URLDecoder.decode(raw, "UTF-8") }.getOrDefault(raw) + return "__Secure-commandcode_prod_.session_token=$decoded" + } + // fallback 2: token 字段(用户把 cookie 值填到了 API Key 框?不会走到这里, + // 因为 API Key 在 token 字段用于 Bearer 认证;这里仅防御性处理) + throw RepositoryError.InvalidCredential("Cookie 无效:请在凭据页粘贴 commandcode.ai 的完整 Cookie 字符串(F12 → Application → Cookies → commandcode.ai 全选复制)") } /**