fix: ParseError 原因结构化本地化,消除英文/繁体界面的中文错误透传(Codex review + 双审计)

This commit is contained in:
WaterRain 2026-08-02 01:16:52 +00:00
parent 2e4e5ce449
commit 039f27da18
No known key found for this signature in database
13 changed files with 86 additions and 35 deletions

View File

@ -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 对象")
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)
/** 未知错误 */

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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

View File

@ -427,7 +427,12 @@
<string name="action_save_cookie">保存 Cookie</string>
<!-- ══════════ 服务详情/凭据校验(D 组迁移) ══════════ -->
<string name="error_parse_failed">数据解析失败: %1$s</string>
<string name="error_parse_empty_body">响应体为空</string>
<string name="error_parse_not_json">响应根节点不是 JSON 对象</string>
<string name="error_parse_no_windows">未找到用量窗口数据</string>
<string name="error_parse_no_models">未找到模型列表</string>
<string name="error_parse_models_empty">模型列表为空</string>
<string name="error_parse_malformed">响应格式异常</string>
<string name="window_every_week">每周</string>
<string name="msg_model_call_count">模型调用次数</string>
<string name="msg_stale_cache">缓存数据 · %1$s</string>

View File

@ -427,7 +427,12 @@
<string name="action_save_cookie">儲存 Cookie</string>
<!-- ══════════ 服务详情/凭据校验(D 组迁移) ══════════ -->
<string name="error_parse_failed">數據解析失敗: %1$s</string>
<string name="error_parse_empty_body">回應體為空</string>
<string name="error_parse_not_json">回應根節點不是 JSON 物件</string>
<string name="error_parse_no_windows">未找到用量視窗資料</string>
<string name="error_parse_no_models">未找到模型清單</string>
<string name="error_parse_models_empty">模型清單為空</string>
<string name="error_parse_malformed">回應格式異常</string>
<string name="window_every_week">每週</string>
<string name="msg_model_call_count">模型呼叫次數</string>
<string name="msg_stale_cache">快取數據 · %1$s</string>

View File

@ -430,7 +430,12 @@
<string name="action_save_cookie">Save cookie</string>
<!-- ══════════ 服务详情/凭据校验(D 组) ══════════ -->
<string name="error_parse_failed">Failed to parse data: %1$s</string>
<string name="error_parse_empty_body">Empty response body</string>
<string name="error_parse_not_json">Response root is not a JSON object</string>
<string name="error_parse_no_windows">No usage window data found</string>
<string name="error_parse_no_models">Model list not found</string>
<string name="error_parse_models_empty">Model list is empty</string>
<string name="error_parse_malformed">Response format is abnormal</string>
<string name="window_every_week">Weekly</string>
<string name="msg_model_call_count">Model call count</string>
<string name="msg_stale_cache">Cached data · %1$s</string>