From 58d8b0e077a99d1bf4b382d1718dc55919b74e41 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Sat, 12 Sep 2026 03:41:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(sub2api):=20=E6=8C=89=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=AE=9E=E6=B5=8B=E6=A0=A1=E5=87=86=E5=85=91?= =?UTF-8?q?=E6=8D=A2/=E9=87=91=E9=A2=9D/=E6=A8=A1=E5=9E=8B=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测环境:xxcsn.site(无忧ai)v0.2.1,浏览器登录态直连接口逐条比对响应结构。 修复(均为实测发现,非推测): - 兑换失败文案被吞:失败走 HTTP 404/409 + {code,message,reason},原先先判状态再解析, 会把「兑换码不存在 / 已被使用」降级成无文案的通用错误。改为先解析 body, 并把 REDEEM_CODE_NOT_FOUND/USED/EXPIRED/DISABLED 映射为中文,未知 reason 透传面板原文。 - 兑换成功不显示金额:成功响应 data.value 是数字(实测 value:50),原用 scalar("value") 取不到, 「兑换成功」后面没有金额。改用 numberOr + trimNum,并附带类型后缀(余额/订阅)。 - 金额少一位(严重):trimNum 原实现 v.toString().trimEnd('0','.'),Kotlin 的 trimEnd 会连续 剥离集合内任意字符——"50.0"→"5"、"100.0"→"1"、"10.0"→"1"(已在 JDK 上复现)。 即兑换 $50 会显示成 +$5。改为 BigDecimal.valueOf(v).stripTrailingZeros().toPlainString()。 同一份错误实现还拷贝在 TraeRepository / WorkBuddyRepository,一并修复。 - 按模型无法按周期筛选(Key 模式根因):实测下游 /v1/usage 的 model_stats 恒为历史累计口径, days/start_date/end_date 只影响 daily_usage。新增 fetchKeyScopedWindowModelStats(): 有面板登录态时先用 /api/v1/keys 反查当前 sk-key 的 id,再走支持日期窗口与 api_key_id 的 /api/v1/usage/dashboard/models(实测同一 key:today=0 / 近7天=8 / 近90天=8 条)。 - 累计口径如实标注:新增 WindowModelStats(stats, allTime);纯 Key 无面板态时回退历史累计, UI 在「按模型统计」标题追加「(累计口径,未按所选周期过滤)」,避免冒充周期数据(三语)。 - API Key 掩码对齐面板网页观感(sk-06b...2dc0);列表接口虽返回完整 key,页面仍只显示掩码, 复制按钮复制的始终是完整值。 实测核对结果:首页 今日 $1.58/936req 与 近7天 $132.49/9893req 周期区分有效; 90 天趋势网关只返回 89 天,补零后 90 天(验证 slice() 补零逻辑必要且正确); 按模型 today=1 / 近7天=11 / 近90天=34 条,周期筛选生效; 兑换成功路径真实执行一次(id=57,$50,余额 309.29→359.29),失败路径 404/409 文案正确。 验证:compileDebugKotlin、lintDebug 均通过;独立审计两轮均无阻断与必修体验问题。 注:测试用临时 key 已删除;gradle.properties 的本地路径开关未纳入提交。 --- .../data/repository/Sub2ApiRepository.kt | 145 +++++++++++++++--- .../token/data/repository/TraeRepository.kt | 8 +- .../data/repository/WorkBuddyRepository.kt | 8 +- .../servicedetail/ServiceDetailViewModel.kt | 8 +- .../servicedetail/Sub2ApiPanelManagement.kt | 8 +- .../ui/servicedetail/Sub2ApiUsageCard.kt | 7 +- app/src/main/res/values-b+zh+Hans/strings.xml | 1 + app/src/main/res/values-b+zh+Hant/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 9 files changed, 158 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt b/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt index d10892a..86bf0b3 100644 --- a/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt @@ -468,6 +468,15 @@ class Sub2ApiRepository constructor( val quota: Double ) + /** + * 窗口分模型统计结果。 + * + * @param stats 模型统计列表 + * @param allTime 数据是否为「历史累计」口径。仅为 true 时 UI 必须显式标注, + * 因为此时列表并不代表所选周期(下游 /v1/usage 的 model_stats 无法按窗口过滤)。 + */ + data class WindowModelStats(val stats: List, val allTime: Boolean = false) + /** 可用分组(切换分组选择器数据)。 */ data class PanelGroup(val id: Long, val name: String, val rateMultiplier: Double, val description: String = "") @@ -507,7 +516,7 @@ class Sub2ApiRepository constructor( * 面板模式:GET /api/v1/usage/dashboard/models?start_date=&end_date=… * Key 模式:GET /v1/usage?days=&start_date=&end_date=…(404/400 自动兜底 /api/v1/usage)。 */ - suspend fun fetchWindowModelStats(startDate: LocalDate, endDate: LocalDate): Result> = + suspend fun fetchWindowModelStats(startDate: LocalDate, endDate: LocalDate): Result = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先配置 Sub2API 凭据")) @@ -518,19 +527,29 @@ class Sub2ApiRepository constructor( val tz = tzParam() val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() } - val models = if (panelToken != null) { + val models: WindowModelStats = if (panelToken != null) { val auth = AuthHeader("Authorization", "Bearer " + panelToken) val resp = get(base, "/api/v1/usage/dashboard/models?start_date=$start&end_date=$end&model_source=requested&timezone=$tz", auth) ?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("获取窗口模型统计失败"))) panelFailure(resp)?.let { return@withContext Result.failure(it) } - runCatching { parsePanelModels(resp.body) }.getOrElse { e -> + val list = runCatching { parsePanelModels(resp.body) }.getOrElse { e -> return@withContext Result.failure( RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "面板响应解析失败: " + e.message) ) } + WindowModelStats(list.sortedByDescending { it.cost }, allTime = false) } else { val auth = resolveAuth(base, credential) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("Sub2API 认证失败,请检查 API Key")) + // 实测(xxcsn.site v0.2.1):下游 /v1/usage 的 model_stats 是「历史累计」口径, + // days / start_date / end_date 只影响 daily_usage,不影响 model_stats—— + // 所以直接用它无法回答「本周期用了哪些模型」。 + // 先用当前 sk-key 反查它在面板里的 id,再走带日期窗口的 dashboard/models + //(该端点支持 api_key_id 且窗口生效,实测 key 维度 today=0 / 7d=8 条)。 + val scoped = fetchKeyScopedWindowModelStats(base, panelToken, credential, start, end, tz) + if (scoped != null) return@withContext scoped + + // 兜底:只能拿到历史累计口径,仍返回结果,但由调用方按「累计」展示,避免误导成周期数据。 val days = (ChronoUnit.DAYS.between(startDate, endDate) + 1).coerceIn(1L, 90L) val query = "days=$days&start_date=$start&end_date=$end&timezone=$tz" val resp = get(base, "/v1/usage?$query", auth)?.takeIf { it.code != 404 && it.code != 400 } @@ -541,15 +560,60 @@ class Sub2ApiRepository constructor( if (resp.code !in 200..299) { return@withContext Result.failure(mapSubHttpError(resp.code)) } - runCatching { parseUsageDetail(resp.body).modelStats }.getOrElse { e -> + val list = runCatching { parseUsageDetail(resp.body).modelStats }.getOrElse { e -> return@withContext Result.failure( RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "sub2api 窗口模型统计解析失败: " + e.message) ) } + // allTime=true:这是历史累计口径,UI 必须标注「累计」,不能冒充所选周期 + WindowModelStats(list.sortedByDescending { it.cost }, allTime = true) } - Result.success(models.sortedByDescending { it.cost }) + Result.success(models) } + /** + * Key 模式下按日期窗口取分模型统计(实测 xxcsn.site v0.2.1)。 + * + * 背景:下游 /v1/usage 的 model_stats 恒为历史累计口径,days/start_date/end_date 不生效, + * 因此无法直接回答「本周期的模型分布」。可行的替代路径: + * 1) 用面板登录态 GET /api/v1/keys 找到当前 sk-key 对应的 id; + * 2) GET /api/v1/usage/dashboard/models?...&api_key_id= —— 该端点窗口生效 + * (实测:同一 key,today=0 条 / 近7天=8 条 / 近90天=8 条;账号维度 today=1 条)。 + * + * @return 成功时返回窗口内统计;无面板登录态 / 找不到 key id / 请求失败时返回 null, + * 由调用方回退到历史累计口径。 + */ + private suspend fun fetchKeyScopedWindowModelStats( + base: String, + panelToken: String?, + credential: Credential.Sub2ApiCredential, + start: String, + end: String, + tz: String + ): Result? { + if (panelToken == null) return null + val auth = AuthHeader("Authorization", "Bearer " + panelToken) + val rawKey = credential.apiKey?.trim().takeUnless { it.isNullOrEmpty() } ?: return null + val keyId = runCatching { + val resp = get(base, "/api/v1/keys?page=1&page_size=100&timezone=" + tz, auth) ?: return null + if (panelFailure(resp) != null) return null + val items = json.parseToJsonElement(resp.body).jsonObject["data"]?.jsonObject?.get("items") as? JsonArray + ?: return null + items.firstOrNull { it.jsonObject.scalar("key") == rawKey }?.jsonObject?.longOr("id") + }.getOrNull() ?: return null + + return runCatching { + val resp = get( + base, + "/api/v1/usage/dashboard/models?start_date=$start&end_date=$end" + + "&model_source=requested&api_key_id=$keyId&timezone=$tz", + auth + ) ?: return@runCatching null + panelFailure(resp)?.let { return@runCatching Result.failure(it) } + Result.success(WindowModelStats(parsePanelModels(resp.body).sortedByDescending { it.cost }, allTime = false)) + }.getOrNull() + } + /** * 新建 Key:POST /api/v1/keys {name, group_id?} → 返回明文 sk-key。 * 兼容多种响应形态:data 为对象(key / api_key / sk_key / token 字段)、data 为字符串, @@ -646,24 +710,55 @@ class Sub2ApiRepository constructor( val body = buildJsonObject { put("code", code.trim()) }.toString() val resp = post(base, "/api/v1/redeem", auth, body) ?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("兑换失败"))) - panelFailure(resp)?.let { return@withContext Result.failure(it) } - runCatching { - val obj = json.parseToJsonElement(resp.body).jsonObject - val codeNum = obj.numberOr("code") - val successFlag = obj["success"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() - val msg = obj.scalar("message") ?: "" - val ok = when { - codeNum != null -> codeNum == 0.0 - successFlag != null -> successFlag - // 既无 code 也无 success:响应里能解析出 data 即视为成功 - else -> obj["data"] != null + // 兑换失败时面板用 HTTP 404/400 + {code,message,reason} 表达业务错误 + // (实测:code=404, message="redeem code not found", reason=REDEEM_CODE_NOT_FOUND)。 + // 因此这里必须先解析 body 再判定状态,否则会把「兑换码不存在」等具体原因吞成通用 HTTP 错误。 + val parsed = runCatching { json.parseToJsonElement(resp.body).jsonObject }.getOrNull() + ?: return@withContext Result.failure( + RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "兑换响应解析失败(HTTP " + resp.code + ")") + ) + val codeNum = parsed.numberOr("code") + val successFlag = parsed["success"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() + val msg = parsed.scalar("message", "error", "reason") ?: "" + val ok = resp.code in 200..299 && when { + codeNum != null -> codeNum == 0.0 + successFlag != null -> successFlag + // 既无 code 也无 success:响应里能解析出 data 即视为成功 + else -> parsed["data"] != null + } + if (!ok) { + // 面板用英文 message + 机器可读 reason 表达业务错误(实测: + // 404 REDEEM_CODE_NOT_FOUND / 409 REDEEM_CODE_USED)。 + // 中文用户看不懂英文原文,这里把已确认的 reason 译成中文,未知 reason 原样透传便于排查。 + val reason = parsed.scalar("reason") + val localized = when (reason) { + "REDEEM_CODE_NOT_FOUND" -> "兑换码不存在,请检查是否输入正确" + "REDEEM_CODE_USED" -> "该兑换码已被使用" + "REDEEM_CODE_EXPIRED" -> "该兑换码已过期" + "REDEEM_CODE_DISABLED" -> "该兑换码已被禁用" + else -> null } - if (ok) { - Result.success("兑换成功" + (obj["data"]?.jsonObject?.scalar("value")?.let { "(+{$it})" } ?: "")) - } else { - Result.failure(RepositoryError.Unknown(IllegalStateException(msg.ifBlank { "兑换失败" }))) + // 业务错误优先用面板文案;凭据失效仍按 401/403 归类,便于 UI 区分「去登录」 + val text = localized ?: msg.ifBlank { "兑换失败(HTTP " + resp.code + ")" } + val business = RepositoryError.Unknown(IllegalStateException(text)) + val failure = when (resp.code) { + 401, 403 -> RepositoryError.InvalidCredential("Sub2API 面板登录已失效,请重新网页登录") + else -> business } - }.getOrElse { Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "兑换响应解析失败")) } + return@withContext Result.failure(failure) + } + // 实测成功响应:{"code":0,"data":{"id":57,"type":"balance","value":50,"status":"used",...}} + // 注意 value 是数字(50)而非字符串,必须走 numberOr,否则金额显示不出来。 + // data 期望是对象;若某实例返回字符串/数字,jsonObject 会抛异常中断协程,这里降级为「无金额」文案 + val data = parsed["data"] as? JsonObject + val amount = data?.numberOr("value") + val typeSuffix = when (data?.scalar("type")) { + "balance" -> " 余额" + "subscription" -> " 订阅" + else -> "" + } + val amountText = amount?.let { "(+" + trimNum(it) + typeSuffix + ")" } ?: "" + Result.success("兑换成功" + amountText) } /** 面板凭证获取(token 非空时);返回 (auth, base) 或 null。 */ @@ -783,7 +878,13 @@ class Sub2ApiRepository constructor( ) } - private fun trimNum(v: Double): String = v.toString().trimEnd('0', '.') + /** + * 数值转简短显示串(去掉多余的 0 与小数点)。 + * 注意不能用 v.toString().trimEnd('0','.'):trimEnd 会连续剥离集合内任意字符, + * "50.0" 会被剪成 "5"、"100.0" 剪成 "1"(实测兑换 $50 显示成 +$5 的原因)。 + */ + private fun trimNum(v: Double): String = + java.math.BigDecimal.valueOf(v).stripTrailingZeros().toPlainString() private fun JsonObject.scalar(vararg keys: String): String? { for (key in keys) { diff --git a/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt b/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt index ca61967..37a5ede 100644 --- a/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt @@ -302,7 +302,13 @@ class TraeRepository constructor( else -> RepositoryError.Unknown() } - private fun trimNum(v: Double): String = v.toString().trimEnd('0', '.') + /** + * 数值转简短显示串(去掉多余的 0 与小数点)。 + * 注意不能用 v.toString().trimEnd('0','.'):trimEnd 会连续剥离集合内任意字符, + * "50.0" 会被剪成 "5"、"100.0" 剪成 "1"(余额/签到金额会显示错一位)。 + */ + private fun trimNum(v: Double): String = + java.math.BigDecimal.valueOf(v).stripTrailingZeros().toPlainString() private data class ParsedTraeUsage( val amount: Double, diff --git a/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt b/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt index 8539279..a9ff1a9 100644 --- a/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt @@ -257,7 +257,13 @@ class WorkBuddyRepository constructor( return if (raw.equals("credits", ignoreCase = true)) "Credits" else raw } - private fun trimNum(v: Double): String = v.toString().trimEnd('0', '.') + /** + * 数值转简短显示串(去掉多余的 0 与小数点)。 + * 注意不能用 v.toString().trimEnd('0','.'):trimEnd 会连续剥离集合内任意字符, + * "50.0" 会被剪成 "5"、"100.0" 剪成 "1"(余额/签到金额会显示错一位)。 + */ + private fun trimNum(v: Double): String = + java.math.BigDecimal.valueOf(v).stripTrailingZeros().toPlainString() private fun JsonObject.number(vararg keys: String): Double? { for (key in keys) { 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 23e085d..b405da4 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 @@ -117,7 +117,7 @@ class ServiceDetailViewModel @Inject constructor( // 过时结果(用户已切换到别的窗口)直接丢弃,不写状态 if (generation != panelWindowStatsGeneration) return@launch result.fold( - onSuccess = { list -> _panelWindowStats.value = PanelWindowStatsState.Success(list) }, + onSuccess = { r -> _panelWindowStats.value = PanelWindowStatsState.Success(r.stats, r.allTime) }, onFailure = { e -> _panelWindowStats.value = PanelWindowStatsState.Error(e.message ?: "加载模型统计失败") } @@ -769,7 +769,11 @@ sealed class PanelKeysState { sealed class PanelWindowStatsState { data object Idle : PanelWindowStatsState() data object Loading : PanelWindowStatsState() - data class Success(val stats: List) : PanelWindowStatsState() + /** @param allTime 数据为历史累计口径(下游接口无法按窗口过滤),UI 需显式标注 */ + data class Success( + val stats: List, + val allTime: Boolean = false + ) : PanelWindowStatsState() data class Error(val message: String) : PanelWindowStatsState() } diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiPanelManagement.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiPanelManagement.kt index 80be7d0..59d884b 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiPanelManagement.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiPanelManagement.kt @@ -397,10 +397,14 @@ internal fun Sub2ApiPanelManagement( } } -/** API Key 展示掩码:只留前 8 + 后 4 位。 */ +/** + * API Key 展示掩码:与面板网页保持一致的观感(sk-xxxx...yyyy)。 + * 列表接口虽然返回完整 key(实测 67 字符),但页面上不该铺满明文, + * 复制按钮复制的仍是完整值(见 copyToClipboard(context, key.key, …))。 + */ private fun maskKey(key: String): String = when { key.length <= 12 -> key - else -> key.take(8) + "…" + key.takeLast(4) + else -> key.take(7) + "..." + key.takeLast(4) } /** 复制到剪贴板并 Toast 反馈(用户感知「复制按钮」有效)。 */ diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt index 080c87b..05718f9 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt @@ -137,6 +137,10 @@ internal fun Sub2ApiUsageCard( } // 窗口分模型统计:「全部」用网关返回的全量 model_stats,其余按当前周期拉取结果 + // Key 模式下后端口径为「历史累计」时 allTime=true,下面的小标题会如实标注,避免被误读为周期数据 + // 「全部」窗口本身就用网关全量 model_stats,不需要再挂「累计」后缀;只在按周期拉取时才可能命中累计口径 + val windowStatsAllTime = window != Sub2Window.ALL && + (windowStatsState as? PanelWindowStatsState.Success)?.allTime == true val windowModelStats = if (window == Sub2Window.ALL) detail.modelStats else (windowStatsState as? PanelWindowStatsState.Success)?.stats.orEmpty() @@ -249,7 +253,8 @@ internal fun Sub2ApiUsageCard( HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) Spacer(modifier = Modifier.height(14.dp)) Text( - text = stringResource(R.string.sub2_models_header), + text = stringResource(R.string.sub2_models_header) + + if (windowStatsAllTime) stringResource(R.string.sub2_models_alltime_suffix) else "", style = MaterialTheme.typography.labelLarge, color = inkMuted() ) 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 4e1244a..b76d11d 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -671,4 +671,5 @@ %1$s(ID %2$d) API Key 已复制 ⚠ 余额不足:剩余 $%1$s,请及时充值或兑换卡密 + (累计口径,未按所选周期过滤) \ No newline at end of file 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 5956153..91a47db 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -671,4 +671,5 @@ %1$s(ID %2$d) API Key 已複製 ⚠ 餘額不足:剩餘 $%1$s,請及時儲值或兌換卡密 + (累計口徑,未按所選週期過濾) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4629cc9..8329ab0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -676,4 +676,5 @@ %1$s (ID %2$d) API Key copied ⚠ Low balance: $%1$s left — top up or redeem a code + (all-time, not limited to the selected period) \ No newline at end of file