diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index 7c0dd4c..c775f51 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -115,9 +115,9 @@ class CodexRepository( 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}")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}")) } - if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError("未找到 Codex 用量窗口数据")) + if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_WINDOWS, "未找到 Codex 用量窗口数据")) val config = ServiceConfigProvider.get(ServiceType.CODEX) val primary = windows.firstOrNull { it.label.contains("h") } ?: windows.first() @@ -155,16 +155,16 @@ class CodexRepository( DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}") return@withContext Result.failure(RepositoryError.ServerError(resp.code)) } - val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") // 结构: { "openai": { "models": { "gpt-5.6": {...}, ... } } } val openaiProvider = root["openai"] as? JsonObject val modelsObj = openaiProvider?.get("models") as? JsonObject modelsObj?.keys?.toList()?.sorted() - ?: throw RepositoryError.ParseError("未找到 OpenAI 模型列表") + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenAI 模型列表") } if (models.isEmpty()) { - return@withContext Result.failure(RepositoryError.ParseError("模型列表为空")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空")) } DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型") Result.success(models) @@ -386,8 +386,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("响应体为空")) as? JsonObject) - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject) + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") } } 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..2ae118c 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 @@ -106,7 +106,7 @@ class CommandCodeUsageRepository( } if (body == null) return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val records = parseUsageResponse(body) diff --git a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt index 04a45f8..a401d65 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt @@ -178,13 +178,14 @@ class OllamaRepository( } val html = resp.body?.string() ?: return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val parsed = parseUsage(html) if (parsed == null) { return@withContext Result.failure( RepositoryError.ParseError( + RepositoryError.ParseErrorReason.NO_WINDOWS, "解析失败:未找到 Ollama Cloud 用量数据。HTML=${html.length} 字节。Cookie 可能已过期。" ) ) @@ -241,15 +242,15 @@ class OllamaRepository( DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}") return@withContext Result.failure(RepositoryError.ServerError(resp.code)) } - val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") val provider = root["ollama-cloud"] as? JsonObject val modelsObj = provider?.get("models") as? JsonObject modelsObj?.keys?.toList()?.sorted() - ?: throw RepositoryError.ParseError("未找到 Ollama Cloud 模型列表") + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 Ollama Cloud 模型列表") } if (models.isEmpty()) { - return@withContext Result.failure(RepositoryError.ParseError("模型列表为空")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空")) } DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型") Result.success(models) diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt index 087719a..fb56f8d 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt @@ -86,13 +86,14 @@ class OpenCodeGoRepository( } val html = resp.body?.string() ?: return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val windows = parseWindows(html) if (windows.isEmpty()) { return@withContext Result.failure( RepositoryError.ParseError( + RepositoryError.ParseErrorReason.NO_WINDOWS, "解析失败:未找到任何 OpenCode Go 配额窗口。HTML=${html.length} 字节。" ) ) @@ -151,15 +152,15 @@ class OpenCodeGoRepository( DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}") return@withContext Result.failure(RepositoryError.ServerError(resp.code)) } - val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") val provider = root["opencode-go"] as? JsonObject val modelsObj = provider?.get("models") as? JsonObject modelsObj?.keys?.toList()?.sorted() - ?: throw RepositoryError.ParseError("未找到 OpenCode Go 模型列表") + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenCode Go 模型列表") } if (models.isEmpty()) { - return@withContext Result.failure(RepositoryError.ParseError("模型列表为空")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空")) } DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型") Result.success(models) diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt index 26c8f30..55f0a6f 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt @@ -88,7 +88,7 @@ class OpenCodeUsageRepository( } val body = resp.body?.string() ?: return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val records = parseUsageRecords(body) diff --git a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt index cfa38dd..08395d4 100644 --- a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt +++ b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt @@ -23,8 +23,24 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except class ServerError(val code: Int, cause: Throwable? = null) : RepositoryError("服务端异常 (HTTP $code)", cause) - /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因 */ - class ParseError(val detail: String, cause: Throwable? = null) : + /** 解析失败的具体原因(结构化错误码,UI 层据此映射本地化文案,detail 仅作日志/调试) */ + enum class ParseErrorReason { + /** 响应体为空 */ + EMPTY_BODY, + /** 响应根节点不是 JSON 对象 */ + NOT_JSON_OBJECT, + /** 未找到用量窗口数据 */ + NO_WINDOWS, + /** 未找到模型列表 */ + NO_MODELS, + /** 模型列表为空 */ + MODELS_EMPTY, + /** 响应格式异常(detail 含异常信息,仅供日志) */ + MALFORMED_RESPONSE + } + + /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因(日志用,不直接透传 UI) */ + class ParseError(val reason: ParseErrorReason, val detail: String, cause: Throwable? = null) : RepositoryError("解析失败: $detail", cause) /** 未知错误 */ diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt index 9e8fb69..4fe502b 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt @@ -189,7 +189,7 @@ data class DashboardCardUi( val displayBalance: ServiceBalance? get() = cachedBalance?.balance } -/** 把 Repository 错误映射为本地化文案(错误详情 detail 为技术信息,随资源参数展示)。 */ +/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */ private fun errorToUiText(error: Throwable): UiText = when (error) { is RepositoryError.InvalidCredential -> UiText.Resource(R.string.error_credential_invalid_reconfigure) @@ -204,8 +204,14 @@ private fun errorToUiText(error: Throwable): UiText = when (error) { is RepositoryError.Network -> UiText.Resource(R.string.error_network_check) is RepositoryError.ServerError -> UiText.Resource(R.string.error_server_http, listOf(error.code)) - is RepositoryError.ParseError -> - UiText.Resource(R.string.error_parse_failed, listOf(error.detail)) + is RepositoryError.ParseError -> when (error.reason) { + RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body) + RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json) + RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows) + RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models) + RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) + } else -> error.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt index b82fc98..79f0da0 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt @@ -536,8 +536,14 @@ class ServiceDetailViewModel @Inject constructor( is RepositoryError.Network -> UiText.Resource(R.string.error_network_check) is RepositoryError.ServerError -> UiText.Resource(R.string.error_server_http, listOf(error.code)) - is RepositoryError.ParseError -> - UiText.Resource(R.string.error_parse_failed, listOf(error.detail)) + is RepositoryError.ParseError -> when (error.reason) { + RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body) + RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json) + RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows) + RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models) + RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) + } else -> error.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index 18529d0..e0811ef 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -177,8 +177,14 @@ class CredentialEditViewModel @Inject constructor( ?: UiText.Resource(R.string.common_unknown) ) ) - is RepositoryError.ParseError -> - UiText.Resource(R.string.error_parse_failed, listOf(err.detail)) + is RepositoryError.ParseError -> when (err.reason) { + RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body) + RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json) + RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows) + RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models) + RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) + } else -> err?.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties index 3c5afff..6005758 100644 --- a/app/src/main/res/resources.properties +++ b/app/src/main/res/resources.properties @@ -1,5 +1,5 @@ # 默认(无 qualifier)资源对应的语言:英文(国际回退语言) -# 非中文/英文系统语言会回退到默认英文资源;中文系统走 values-zh -# AGP 据此 + values-zh 目录生成 locale config,Android 13+ 系统设置中 -# 会显示 RainyToken 的独立语言选项(English / 简体中文) +# 非中文/英文系统语言会回退到默认英文资源;中文系统走 values-b+zh+Hans / values-b+zh+Hant +# AGP 据此 + 中文资源目录生成 locale config,Android 13+ 系统设置中 +# 会显示 RainyToken 的独立语言选项(English / 简体中文 / 繁體中文) unqualifiedResLocale=en diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 8bc27aa..52383f4 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -427,7 +427,12 @@ 保存 Cookie - 数据解析失败: %1$s + 响应体为空 + 响应根节点不是 JSON 对象 + 未找到用量窗口数据 + 未找到模型列表 + 模型列表为空 + 响应格式异常 每周 模型调用次数 缓存数据 · %1$s diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index 9ed8670..12821b7 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -427,7 +427,12 @@ 儲存 Cookie - 數據解析失敗: %1$s + 回應體為空 + 回應根節點不是 JSON 物件 + 未找到用量視窗資料 + 未找到模型清單 + 模型清單為空 + 回應格式異常 每週 模型呼叫次數 快取數據 · %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 67546ab..5d82a0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -430,7 +430,12 @@ Save cookie - Failed to parse data: %1$s + Empty response body + Response root is not a JSON object + No usage window data found + Model list not found + Model list is empty + Response format is abnormal Weekly Model call count Cached data · %1$s