fix: 同步/触发错误全链路本地化(count_times 单位 + Unknown/PartialSync/TriggerError 结构化映射)

This commit is contained in:
WaterRain 2026-08-02 03:02:58 +00:00
parent 039f27da18
commit f788d57798
No known key found for this signature in database
10 changed files with 68 additions and 18 deletions

View File

@ -252,7 +252,7 @@ class CodexRepository(
}
is RefreshResult.Failure -> {
DebugLog.e(TAG, "triggerUsage: 二次刷新失败: ${r.reason}")
return@use Result.failure(TriggerError("token 刷新失败: ${r.reason}", ""))
return@use Result.failure(TriggerError("token 刷新失败: ${r.reason}", "", TriggerErrorReason.TOKEN_REFRESH))
}
}
} else {
@ -394,8 +394,14 @@ class CodexRepository(
@Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null)
}
/** TriggerError 的结构化原因(UI 层据此映射本地化文案;null = HTTP 错误,summary 为 ASCII 可直显) */
enum class TriggerErrorReason {
/** token 刷新失败(summary 中为中文用户提示,不直接透传 UI) */
TOKEN_REFRESH
}
/** 携带完整响应体的错误类,供 UI 展示服务端返回的详细信息 */
class TriggerError(val summary: String, val responseBody: String) : Exception(summary)
class TriggerError(val summary: String, val responseBody: String, val reason: TriggerErrorReason? = null) : Exception(summary)
/**
* 将 SSE 流响应解析为简洁的文本摘要。

View File

@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.ui.components.asString
import com.rainy.token.data.local.OverviewStats
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
@ -164,7 +165,7 @@ fun CommandCodeUsageStatsCard(
uiState.lastSyncError?.let { err ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "✗ $err",
text = "✗ ${err.asString()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)

View File

@ -212,6 +212,8 @@ private fun errorToUiText(error: Throwable): UiText = when (error) {
RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
}
// Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案
is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown)
else -> error.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.common_unknown)
}

View File

@ -31,6 +31,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.data.local.OverviewStats
import com.rainy.token.ui.components.asString
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
import java.util.Locale
@ -163,7 +164,7 @@ fun UsageStatsCard(
uiState.lastSyncError?.let { err ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "✗ $err",
text = "✗ ${err.asString()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)

View File

@ -9,10 +9,13 @@ import com.rainy.token.data.local.ModelStats
import com.rainy.token.data.local.DailyStats
import com.rainy.token.data.local.UsageCache
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.SyncCommandCodeUsageUseCase
import com.rainy.token.domain.usecase.SyncError
import com.rainy.token.domain.usecase.SyncUsageUseCase
import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@ -203,7 +206,7 @@ class UsageViewModel @Inject constructor(
it.copy(
syncing = false,
lastSyncResult = result.getOrNull()?.inserted ?: 0,
lastSyncError = result.exceptionOrNull()?.message
lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) }
)
}
}
@ -224,7 +227,7 @@ class UsageViewModel @Inject constructor(
it.copy(
syncing = false,
lastSyncResult = result.getOrNull()?.inserted ?: 0,
lastSyncError = result.exceptionOrNull()?.message
lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) }
)
}
}
@ -243,7 +246,7 @@ data class UsageUiState(
val dailyStats: List<DailyStats> = emptyList(),
val recordCount: Int = 0,
val lastSyncResult: Int = 0,
val lastSyncError: String? = null,
val lastSyncError: UiText? = null,
val timeFilter: TimeFilter = TimeFilter.All,
val dailyPage: Int = 1,
val modelFilter: String? = null // null = 全部模型
@ -256,3 +259,34 @@ private data class LoadResult(
val dailyStats: List<DailyStats>,
val totalCount: Int
)
/** 把同步错误映射为本地化文案:SyncError / RepositoryError 全部走资源,不透传中文 message。 */
private fun syncErrorToUiText(error: Throwable): UiText = when (error) {
is SyncError.PartialSync ->
UiText.Resource(R.string.sync_partial, listOf(error.inserted, error.errors.size))
is RepositoryError.InvalidCredential ->
UiText.Resource(R.string.error_credential_invalid_reconfigure)
is RepositoryError.CredentialChanged ->
UiText.Resource(R.string.error_credential_changed_reload)
is RepositoryError.RateLimited -> UiText.Resource(
R.string.error_rate_limited_retry,
listOf(
error.retryAfterSeconds?.let {
UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it))
} ?: ""
)
)
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 -> 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)
}
is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown)
else -> UiText.Resource(R.string.common_unknown)
}

View File

@ -8,6 +8,7 @@ import com.rainy.token.data.debug.DebugLog
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.TriggerError
import com.rainy.token.data.repository.TriggerErrorReason
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceBalance
@ -337,12 +338,10 @@ class ServiceDetailViewModel @Inject constructor(
val responseBody: String?
when (error) {
is TriggerError -> {
val prefix = "token 刷新失败: "
message = if (error.summary.startsWith(prefix)) {
UiText.Resource(
R.string.error_token_refresh_failed,
listOf(error.summary.removePrefix(prefix))
)
// reason 结构化:token 刷新失败显示本地化文案(summary 中文提示只进日志);
// 其余(HTTP 错误)summary 为 ASCII 可直接展示
message = if (error.reason == TriggerErrorReason.TOKEN_REFRESH) {
UiText.Resource(R.string.error_token_refresh_generic)
} else {
UiText.Dynamic(error.summary)
}
@ -544,6 +543,8 @@ class ServiceDetailViewModel @Inject constructor(
RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
}
// Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案
is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown)
else -> error.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.common_unknown)
}

View File

@ -185,6 +185,8 @@ class CredentialEditViewModel @Inject constructor(
RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
}
// Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案
is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown)
else -> err?.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.common_unknown)
}

View File

@ -139,6 +139,7 @@
<string name="usage_cache_write">缓存写入 %1$s</string>
<string name="usage_no_data_sync">暂无数据,点击 🔄 同步</string>
<string name="usage_new_records">✓ 新增 %1$d 条</string>
<string name="sync_partial">部分同步完成:插入 %1$d条,%2$d页失败</string>
<string name="usage_click_to_switch"> 点击切换</string>
<string name="usage_utc0_note">所有时间按 UTC+0 计算</string>
<string name="msg_utc8_on">已切换到 UTC+8,图表按北京时间显示</string>
@ -273,7 +274,7 @@
<string name="trigger_parse_failed">(响应解析失败,但请求已发出)</string>
<string name="trigger_usage_prompt_completion_total">用量: prompt=%1$s completion=%2$s total=%3$s tokens</string>
<string name="trigger_usage_input_output">用量: input=%1$s output=%2$s tokens</string>
<string name="error_token_refresh_failed">token 刷新失败:%1$s</string>
<string name="error_token_refresh_generic">token 刷新失败,请重新登录或重新导入 auth.json</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="title_webview_login">登录 %1$s</string>

View File

@ -139,6 +139,7 @@
<string name="usage_cache_write">快取寫入 %1$s</string>
<string name="usage_no_data_sync">暫無數據,點選 🔄 同步</string>
<string name="usage_new_records">✓ 新增 %1$d 條</string>
<string name="sync_partial">部分同步完成:插入 %1$d條,%2$d頁失敗</string>
<string name="usage_click_to_switch"> 點選切換</string>
<string name="usage_utc0_note">所有時間按 UTC+0 計算</string>
<string name="msg_utc8_on">已切換到 UTC+8,圖表按北京時間顯示</string>
@ -273,7 +274,7 @@
<string name="trigger_parse_failed">(響應解析失敗,但請求已發出)</string>
<string name="trigger_usage_prompt_completion_total">用量: prompt=%1$s completion=%2$s total=%3$s tokens</string>
<string name="trigger_usage_input_output">用量: input=%1$s output=%2$s tokens</string>
<string name="error_token_refresh_failed">token 重新整理失敗:%1$s</string>
<string name="error_token_refresh_generic">token 重新整理失敗,請重新登入或重新匯入 auth.json</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="title_webview_login">登入 %1$s</string>

View File

@ -142,6 +142,7 @@
<string name="usage_cache_write">Cache write %1$s</string>
<string name="usage_no_data_sync">No data yet · tap 🔄 to sync</string>
<string name="usage_new_records">✓ %1$d new records</string>
<string name="sync_partial">Partial sync: %1$d records inserted, %2$d pages failed</string>
<string name="usage_click_to_switch"> tap to switch</string>
<string name="usage_utc0_note">All times in UTC+0</string>
<string name="msg_utc8_on">Switched to UTC+8, charts shown in Beijing time</string>
@ -276,7 +277,7 @@
<string name="trigger_parse_failed">(response parsing failed, but the request was sent)</string>
<string name="trigger_usage_prompt_completion_total">Usage: prompt=%1$s completion=%2$s total=%3$s tokens</string>
<string name="trigger_usage_input_output">Usage: input=%1$s output=%2$s tokens</string>
<string name="error_token_refresh_failed">Token refresh failed: %1$s</string>
<string name="error_token_refresh_generic">Token refresh failed. Please log in again or import auth.json.</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="title_webview_login">Sign in to %1$s</string>
@ -338,7 +339,7 @@
<string name="tip_13_detail">The widget refreshes balances when you swipe to it, or tap the ↻ button manually.</string>
<!-- ══════════ 图表/明细 ══════════ -->
<string name="count_times">%1$d</string>
<string name="count_times">%1$d calls</string>
<string name="date_select_day">Pick a date</string>
<string name="date_select_month">Pick a month (any day)</string>
<string name="date_pick_start">Start date</string>