fix: Codex JsonNull 崩溃 + 5h 窗口优雅降级

- CodexRepository: 所有 ?.jsonObject/?.jsonPrimitive → as? JsonObject/as? JsonPrimitive
  防止 API 返回 JSON null 时 IllegalArgumentException 闪退
- CodexRepository: parseUsageWindows() 外包 try/catch → ParseError 兜底
- CodexRepository: extras 新增 primary.label 供 UI 动态显示主窗口标签
- ServiceBalanceCards: CodexMainBalance 标签动态化 + CodexUsageWindows 保留 5h 空槽位
- ServiceDetailScreen: mainCardLabel 改为 "用量" + CodexUsageCard 保留 5h 空槽位
- Widget: Codex 窗口动态解析 + 5h 始终占第一行
- AGENTS.md: 新增 JsonNull 安全红线 + Codex 5h 降级说明
This commit is contained in:
WaterRain 2026-07-13 02:13:44 +00:00
parent 9d06644225
commit f17d37ae39
No known key found for this signature in database
5 changed files with 87 additions and 19 deletions

View File

@ -23,7 +23,7 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
- ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`
- ✅ CommandCode Go — JSON API 抓取用量数据,`CommandCodeUsageRepository` 解析(workspaceId = `"commandcode"`)
- ✅ Codex / ChatGPT Plus — 支持 **OAuth PKCE 登录(无头模式)** 或粘贴完整 auth.json(含 refresh_token),调 `chatgpt.com/backend-api/wham/usage`;token 过期前 60 分钟自动刷新。OpenAI 采用 refresh_token 单次轮换机制,被外部工具使用后旧 token 立即失效,需重新 OAuth 登录或导入新 auth.json。
- ✅ Codex / ChatGPT Plus — 支持 **OAuth PKCE 登录(无头模式)** 或粘贴完整 auth.json(含 refresh_token),调 `chatgpt.com/backend-api/wham/usage`;token 过期前 60 分钟自动刷新。OpenAI 采用 refresh_token 单次轮换机制,被外部工具使用后旧 token 立即失效,需重新 OAuth 登录或导入新 auth.json。**5h 窗口可能被 OpenAI 临时关闭**(`primary_window` 返回 `null`),UI 保留空 5h 槽位 + 主标签动态化(`extras["primary.label"]`),恢复后自动填回。
- ✅ Ollama Pro — Cookie 认证,OkHttp 抓 `ollama.com/settings` HTML,正则解析 plan/session(5h)/weekly 百分比 + `data-time` 重置时间 + `data-model` 模型级请求次数;无官方 API(ollama/ollama#12532)
- ✅ 文案统一:所有服务标签均使用中文("每周"统一代替 "weekly"/"Weekly"/"weekly")
- ❌ OpenCode Zen / 小米 MiMo — 未实现
@ -41,6 +41,12 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
- ✅ `DebugLog` — 内存 ring buffer(200 条,线程安全),所有 Repository 关键路径(网络请求、Token 刷新、解析错误)均写入日志;设置页提供「调试日志」入口,无需连电脑即可在 APP 内查看 ERROR/WARN/INFO 三级日志。
- ✅ `RepositoryError.InvalidCredential` 支持自定义 `detail`,Codex `RefreshResult` 密封类明确区分刷新成功/失败原因,错误信息可透传到 Dashboard 卡片与调试日志。
**JsonNull 安全红线**:
> ⚠️ kotlinx.serialization 中 `?.jsonObject` / `?.jsonPrimitive` 对 JSON 显式 `null` 值无效——返回的是 `JsonNull` 对象(非 Kotlin null),扩展函数内部类型检查会抛 `IllegalArgumentException` 导致闪退。
> **必须用 `as? JsonObject` / `as? JsonPrimitive` 替代**,安全转换能正确处理 `JsonNull`(返回 null)。
> `parseUsageWindows()` 等解析方法外部必须包 `try/catch` → `RepositoryError.ParseError`,防止 API 响应结构变化导致异常逃逸。
**凭据回显红线**:
> ⚠️ `CredentialEditViewModel.load()` 首次加载已有凭据时,需针对每种 `Credential` 子类显式编写回显分支。

View File

@ -10,6 +10,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@ -84,7 +86,12 @@ class CodexRepository(
} else return@withContext Result.failure(e)
} catch (e: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e)) }
val windows = parseUsageWindows(usageResult)
val windows = try {
parseUsageWindows(usageResult)
} catch (e: Exception) {
DebugLog.e(TAG, "解析 Codex 用量失败: ${e::class.simpleName}: ${e.message}")
return@withContext Result.failure(RepositoryError.ParseError("Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}"))
}
if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError("未找到 Codex 用量窗口数据"))
val config = ServiceConfigProvider.get(ServiceType.CODEX)
@ -95,9 +102,10 @@ class CodexRepository(
put("window_$i.remainingPct", w.remainingPct.toString())
put("window_$i.resetAt", w.resetAt?.toString() ?: "")
}
usageResult["plan_type"]?.jsonPrimitive?.content?.let { put("plan", it) }
usageResult["credits"]?.jsonObject?.let { c ->
c["balance"]?.jsonPrimitive?.floatOrNull?.let { put("usageCredits", it.toString()) }
put("primary.label", primary.label)
(usageResult["plan_type"] as? JsonPrimitive)?.contentOrNull?.let { put("plan", it) }
(usageResult["credits"] as? JsonObject)?.let { c ->
(c["balance"] as? JsonPrimitive)?.floatOrNull?.let { put("usageCredits", it.toString()) }
}
}
val balance = ServiceBalance(ServiceType.CODEX, (100 - primary.remainingPct).coerceIn(0, 100).toDouble(), config.displayUnit, true, null, null, primary.resetAt, extras)
@ -154,7 +162,11 @@ class CodexRepository(
if (errorBody == null) return "HTTP $httpCode,无错误详情"
// 尝试提取 error.message 字段
val msg = try {
json.parseToJsonElement(errorBody).jsonObject["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content
(json.parseToJsonElement(errorBody) as? JsonObject)?.let { root ->
(root["error"] as? JsonObject)?.let { err ->
(err["message"] as? JsonPrimitive)?.contentOrNull
}
}
} catch (_: Exception) { null }
return when {
msg != null && msg.contains("already been used") ->
@ -183,7 +195,8 @@ class CodexRepository(
DebugLog.e(TAG, "fetchJson failed: HTTP ${it.code} ${it.message} url=$url")
throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential("HTTP ${it.code}") else RepositoryError.ServerError(it.code)
}
return json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")).jsonObject
return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject)
?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象")
}
}
@ -194,15 +207,15 @@ class CodexRepository(
fun addWindows(rl: JsonObject?) {
if (rl == null) return
for (key in listOf("primary_window", "secondary_window")) {
val w = rl[key]?.jsonObject ?: continue
val usedPct = w["used_percent"]?.jsonPrimitive?.floatOrNull ?: continue
val w = rl[key] as? JsonObject ?: continue
val usedPct = (w["used_percent"] as? JsonPrimitive)?.floatOrNull ?: continue
val remaining = (100 - usedPct).toInt().coerceIn(0, 100)
result.add(UsageWindow(durationLabel(w["limit_window_seconds"]?.jsonPrimitive?.longOrNull), remaining, w["reset_at"]?.jsonPrimitive?.longOrNull?.times(1000L)))
result.add(UsageWindow(durationLabel((w["limit_window_seconds"] as? JsonPrimitive)?.longOrNull), remaining, (w["reset_at"] as? JsonPrimitive)?.longOrNull?.times(1000L)))
}
}
addWindows(data["rate_limit"]?.jsonObject)
addWindows(data["rate_limit"] as? JsonObject)
(data["additional_rate_limits"] as? kotlinx.serialization.json.JsonArray)?.forEach { item ->
if (item is JsonObject) addWindows(item["rate_limit"]?.jsonObject)
if (item is JsonObject) addWindows(item["rate_limit"] as? JsonObject)
}
return result
}

View File

@ -226,6 +226,7 @@ internal fun CodexMainBalance(balance: ServiceBalance) {
val plan = balance.extras["plan"]?.let {
when (it) { "plus" -> "Plus"; "pro" -> "Pro"; "free" -> "Free"; else -> it.replaceFirstChar { c -> c.uppercaseChar() } }
} ?: "—"
val primaryLabel = formatCodexPrimaryLabel(balance.extras["primary.label"])
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = formatAmount(balance.amount),
@ -242,7 +243,7 @@ internal fun CodexMainBalance(balance: ServiceBalance) {
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "5h 已用",
text = "$primaryLabel 已用",
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
@ -257,6 +258,16 @@ internal fun CodexMainBalance(balance: ServiceBalance) {
}
}
/** 把 Codex API 返回的 primary label 转成中文短标签 */
internal fun formatCodexPrimaryLabel(raw: String?): String = when (raw?.lowercase()) {
"5h" -> "5h"
"7d", "每周" -> "每周"
"30d", "每月" -> "每月"
"usage" -> "用量"
null -> "5h"
else -> raw
}
@Composable
internal fun CodexUsageWindows(balance: ServiceBalance) {
val extras = balance.extras
@ -272,10 +283,17 @@ internal fun CodexUsageWindows(balance: ServiceBalance) {
Triple(label, remainingPct, resetAt)
}
// 判断是否有 5h 窗口(用于保留空槽位)
val has5h = windows.any { it.first.contains("5") && it.first.contains("小时") }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (windows.isEmpty()) {
CompactUsageRowEmpty(label = "Usage", resetInSec = null)
} else {
// 如果没有 5h 窗口,在顶部插入一个空的 5h 槽位(保留位置,等恢复后自动填充)
if (!has5h) {
CompactUsageRowEmpty(label = "5 小时", resetInSec = null)
}
windows.forEach { (label, remainingPct, resetAt) ->
if (remainingPct != null) {
val usedPct = (100 - remainingPct).coerceIn(0, 100)

View File

@ -410,6 +410,9 @@ private fun CodexUsageCard(state: State) {
val windows = remember(extras) { extractCodexWindows(extras) }
if (windows.isEmpty()) return
// 判断是否有 5h 窗口
val has5h = windows.any { it.label.contains("5") && it.label.contains("小时") }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
@ -434,6 +437,20 @@ private fun CodexUsageCard(state: State) {
}
}
Spacer(modifier = Modifier.height(12.dp))
// 没有 5h 窗口时,在顶部插入空槽位
if (!has5h) {
UsageWindowRow(
label = "5 小时",
pct = null,
resetInSec = null,
decimals = 2
)
Spacer(modifier = Modifier.height(14.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(14.dp))
}
windows.forEachIndexed { index, window ->
if (index > 0) {
Spacer(modifier = Modifier.height(14.dp))
@ -850,7 +867,7 @@ private fun mainCardLabel(service: ServiceType): String = when (service) {
ServiceType.DEEPSEEK -> "当前余额"
ServiceType.OPENCODE_GO -> "5h 实时用量"
ServiceType.COMMANDCODE_GO -> "月度余额"
ServiceType.CODEX -> "5h 已用量"
ServiceType.CODEX -> "用量"
ServiceType.OLLAMA -> "Session 用量"
}

View File

@ -177,20 +177,34 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
resetSec = extras["monthly.resetInSec"]?.toLongOrNull())
}
ServiceType.CODEX -> {
val windows = (0 until 3).map { index ->
val label = normalizeWindowLabel(extras["window_$index.label"] ?: if (index == 0) "5h" else "Usage")
val windowCount = extras.keys
.mapNotNull { key -> key.removePrefix("window_").substringBefore('.').toIntOrNull() }
.distinct().maxOrNull()?.plus(1) ?: 0
val windows = (0 until windowCount).map { index ->
val label = normalizeWindowLabel(extras["window_$index.label"] ?: "Usage")
val remaining = extras["window_$index.remainingPct"]?.toIntOrNull()
val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })
}
setRowLabel(views, windows.getOrNull(0)?.first ?: "5h", windows.getOrNull(1)?.first ?: "周", windows.getOrNull(2)?.first ?: "月")
// 判断是否有 5h 窗口
val has5h = windows.any { it.first.contains("5") }
// 始终保留 5h 槽位在第一行
val row1 = if (!has5h) {
Triple("5h", null, null)
} else {
windows.firstOrNull { it.first.contains("5") } ?: windows.getOrNull(0) ?: Triple("5h", null, null)
}
val otherWindows = windows.filter { it != row1 }
val row2 = otherWindows.getOrNull(0) ?: Triple("每周", null, null)
val row3 = otherWindows.getOrNull(1) ?: Triple("每月", null, null)
setRowLabel(views, row1.first, row2.first, row3.first)
listOf(
Triple(R.id.row1_pct, R.id.row1_bar, R.id.row1_reset),
Triple(R.id.row2_pct, R.id.row2_bar, R.id.row2_reset),
Triple(R.id.row3_pct, R.id.row3_bar, R.id.row3_reset)
).forEachIndexed { index, ids ->
val window = windows.getOrNull(index)
populateRow(views, ids.first, ids.second, ids.third, pct = window?.second, resetSec = window?.third)
val window = when (index) { 0 -> row1; 1 -> row2; else -> row3 }
populateRow(views, ids.first, ids.second, ids.third, pct = window.second, resetSec = window.third)
}
}
ServiceType.DEEPSEEK -> setEmptyState(views)