fix(sub2api): 按真实面板实测校准兑换/金额/模型窗口口径
实测环境: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 的本地路径开关未纳入提交。
This commit is contained in:
parent
2aaa225139
commit
58d8b0e077
@ -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<Sub2ModelStat>, 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<List<Sub2ModelStat>> =
|
||||
suspend fun fetchWindowModelStats(startDate: LocalDate, endDate: LocalDate): Result<WindowModelStats> =
|
||||
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,13 +560,58 @@ 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=<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<WindowModelStats>? {
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -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 {
|
||||
// 兑换失败时面板用 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 -> obj["data"] != null
|
||||
else -> parsed["data"] != null
|
||||
}
|
||||
if (ok) {
|
||||
Result.success("兑换成功" + (obj["data"]?.jsonObject?.scalar("value")?.let { "(+{$it})" } ?: ""))
|
||||
} else {
|
||||
Result.failure(RepositoryError.Unknown(IllegalStateException(msg.ifBlank { "兑换失败" })))
|
||||
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
|
||||
}
|
||||
}.getOrElse { Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "兑换响应解析失败")) }
|
||||
// 业务错误优先用面板文案;凭据失效仍按 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
|
||||
}
|
||||
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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<com.rainy.token.domain.model.Sub2ModelStat>) : PanelWindowStatsState()
|
||||
/** @param allTime 数据为历史累计口径(下游接口无法按窗口过滤),UI 需显式标注 */
|
||||
data class Success(
|
||||
val stats: List<com.rainy.token.domain.model.Sub2ModelStat>,
|
||||
val allTime: Boolean = false
|
||||
) : PanelWindowStatsState()
|
||||
data class Error(val message: String) : PanelWindowStatsState()
|
||||
}
|
||||
|
||||
|
||||
@ -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 反馈(用户感知「复制按钮」有效)。 */
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -671,4 +671,5 @@
|
||||
<string name="sub2_panel_key_id">%1$s(ID %2$d)</string>
|
||||
<string name="sub2_panel_copied">API Key 已复制</string>
|
||||
<string name="sub2_panel_low_balance">⚠ 余额不足:剩余 $%1$s,请及时充值或兑换卡密</string>
|
||||
<string name="sub2_models_alltime_suffix">(累计口径,未按所选周期过滤)</string>
|
||||
</resources>
|
||||
@ -671,4 +671,5 @@
|
||||
<string name="sub2_panel_key_id">%1$s(ID %2$d)</string>
|
||||
<string name="sub2_panel_copied">API Key 已複製</string>
|
||||
<string name="sub2_panel_low_balance">⚠ 餘額不足:剩餘 $%1$s,請及時儲值或兌換卡密</string>
|
||||
<string name="sub2_models_alltime_suffix">(累計口徑,未按所選週期過濾)</string>
|
||||
</resources>
|
||||
@ -676,4 +676,5 @@
|
||||
<string name="sub2_panel_key_id">%1$s (ID %2$d)</string>
|
||||
<string name="sub2_panel_copied">API Key copied</string>
|
||||
<string name="sub2_panel_low_balance">⚠ Low balance: $%1$s left — top up or redeem a code</string>
|
||||
<string name="sub2_models_alltime_suffix">(all-time, not limited to the selected period)</string>
|
||||
</resources>
|
||||
Loading…
Reference in New Issue
Block a user