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..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,16 +6,21 @@ 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 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 import okhttp3.Request import java.io.IOException +import java.util.concurrent.TimeUnit import javax.inject.Singleton /** @@ -28,6 +33,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 +61,16 @@ class CommandCodeGoRepository( return@withContext Result.failure(RepositoryError.InvalidCredential()) } - // 顺序拉取 credits + subscriptions(credits 是主要数据源,subscription 用于补充计划信息) - val creditsResult = runCatching { fetchCredits(apiKey) } - val subResult = runCatching { fetchSubscription(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( @@ -71,9 +87,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 +106,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 +155,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 +206,68 @@ 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" } + * + * 使用独立短超时(5s),慢/失败直接返回 null,绝不影响主流程。 + */ + 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") + .header("Accept", "application/json") + .get() + .build() + + val response = try { + client.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 +300,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/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 全选复制)") } /** 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 b323d67..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 @@ -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)独立用量卡。 * @@ -398,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(), @@ -413,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 = "%", @@ -430,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) {