diff --git a/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt index 4b011ec..09efac4 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt @@ -350,6 +350,23 @@ class CredentialRepository @Inject constructor( field("refreshToken", credential.refreshToken) } } + is Credential.TraeCredential -> { + field("type", "trae") + field("jwt", credential.jwt) + field("region", credential.region) + } + is Credential.WorkBuddyCredential -> { + // WorkBuddy 无公开稳定账户 ID;access/refresh token 会话内会轮换, + // 若把 token 纳入身份,每次轮换都会误清余额缓存。这里用固定身份: + // 余额缓存归属同一账号(换账号场景由下一次成功刷新直接覆盖)。 + field("type", "workbuddy") + field("identity", "account") + } + is Credential.Sub2ApiCredential -> { + field("type", "sub2api") + field("baseUrl", credential.baseUrl) + field("email", credential.email) + } } } return sha256(material) @@ -396,6 +413,24 @@ class CredentialRepository @Inject constructor( field("refreshToken", credential.refreshToken) field("accountId", credential.accountId) } + is Credential.TraeCredential -> { + field("type", "trae") + field("jwt", credential.jwt) + field("region", credential.region) + } + is Credential.WorkBuddyCredential -> { + field("type", "workbuddy") + field("accessToken", credential.accessToken) + field("refreshToken", credential.refreshToken) + field("expiresAt", credential.expiresAt.toString()) + } + is Credential.Sub2ApiCredential -> { + field("type", "sub2api") + field("baseUrl", credential.baseUrl) + field("email", credential.email) + field("password", credential.password) + field("apiKey", credential.apiKey) + } } } return sha256(material) 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 new file mode 100644 index 0000000..51b0b68 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt @@ -0,0 +1,374 @@ +package com.rainy.token.data.repository + +import com.rainy.token.data.cache.BalanceCache +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.service.ServiceConfigProvider +import com.rainy.token.domain.model.Sub2DailyUsage +import com.rainy.token.domain.model.Sub2ModelStat +import com.rainy.token.domain.model.Sub2UsageDetail +import com.rainy.token.domain.service.ServiceType +import java.util.TimeZone +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * Sub2API 余额仓库(自托管 mid 层/聚合面板,可配置任意实例)。 + * + * 面向【普通用户】——任何持有本实例 `sk-` 下游 Key 的用户都能查自己的余额, + * 不需要管理员凭据(2026-09 实测 xxcsn.site 确认): + * - 认证:`Authorization: Bearer `(sk-…) + * 备选:无 API Key 时 POST {base}/api/v1/auth/login {email,password} → Bearer token + * - 查询:GET {base}/v1/usage (部分实例是 /api/v1/usage,自动兜底) + * + * 真实响应(实测结构): + * ```json + * { + * "remaining": 302.29, "balance": 302.29, "unit": "USD", "planName": "钱包余额", + * "isValid": true, "mode": "unrestricted", + * "usage": { "total": {"cost": 32.06, "actual_cost": ...}, "today": {"cost": 4.03, ...} }, + * "model_stats": [ {"model":"deepseek-v4-flash","requests":..,"total_tokens":..,"cost":..}, ... ], + * "daily_usage": [ {"date":"2026-09-04","requests":..,"cost":..}, ... ] + * } + * ``` + * 字段名多版兼容(remaining/balance、unit/currency、cost/actual_cost),逐层防御扫描。 + */ +@Singleton +class Sub2ApiRepository constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, + private val balanceCache: BalanceCache +) { + + private val json = Json { ignoreUnknownKeys = true } + private val jsonMedia = "application/json".toMediaType() + + suspend fun fetchBalance(): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.SUB2API) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) + if (credential !is Credential.Sub2ApiCredential) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + val base = normalizeBase(credential.baseUrl) + if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential()) + + val auth = resolveAuth(base, credential) ?: return@withContext Result.failure( + RepositoryError.InvalidCredential("Sub2API 认证失败:请确认 API Key 有效,或用面板账号邮箱+密码") + ) + + // 主端点 /v1/usage;404 时兜底 /api/v1/usage。 + // 带 days=90 多取每日明细(官方前端同款参数,网关不支持时忽略参数仍 200), + // timezone 保证 daily_usage 的 date 按用户所在时区聚合。 + val usageQuery = "days=90&timezone=" + java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8") + val resp = get(base, "/v1/usage?$usageQuery", auth) + ?.takeIf { it.code != 404 && it.code != 400 } + ?: get(base, "/api/v1/usage?$usageQuery", auth) + ?.takeIf { it.code != 404 && it.code != 400 } + // 严格网关不认 days/timezone 参数(400)时,回退无参裸端点 + ?: get(base, "/v1/usage", auth) + ?.takeIf { it.code != 404 } + ?: get(base, "/api/v1/usage", auth) + ?: return@withContext Result.failure(RepositoryError.Unknown()) + if (resp.code !in 200..299) { + return@withContext Result.failure(mapSubHttpError(resp.code)) + } + val stats = runCatching { parseUsageStats(resp.body) }.getOrElse { e -> + return@withContext Result.failure( + RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "sub2api /v1/usage 解析失败: ${e.message}") + ) + } + val unit = stats.unit ?: ServiceConfigProvider.get(ServiceType.SUB2API).displayUnit + val balance = ServiceBalance( + service = ServiceType.SUB2API, + amount = stats.remaining, + unit = unit, + isAvailable = stats.valid, + monthlySpent = stats.totalSpent, + totalQuota = stats.totalQuota, + extras = stats.extras, + sub2UsageDetail = parseUsageDetail(resp.body).takeIf { it.isNotEmpty } + ) + balanceCache.put(ServiceType.SUB2API, balance) + credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis())) + Result.success(balance) + } + + // ---------- 认证 ---------- + + /** 返回可用认证头;失败返回 null。有 API Key 走 Bearer(普通用户推荐),否则登录换 token。 */ + private fun resolveAuth(base: String, credential: Credential.Sub2ApiCredential): AuthHeader? { + if (!credential.apiKey.isNullOrBlank()) { + return AuthHeader("Authorization", "Bearer ${credential.apiKey.trim()}") + } + val body = buildJsonObject { + put("email", credential.email) + put("password", credential.password) + }.toString() + val loginUrl = "$base/api/v1/auth/login" + val request = Request.Builder() + .url(loginUrl) + .addHeader("Content-Type", "application/json") + .post(body.toRequestBody(jsonMedia)) + .build() + return try { + okHttpClient.newCall(request).execute().use { resp -> + val text = resp.body?.string().orEmpty() + val token = when { + resp.isSuccessful -> findToken(text) + resp.code == 423 -> { + // 登录前可能要求先接受合规条款 + acceptCompliance(base) + okHttpClient.newCall( + Request.Builder() + .url(loginUrl) + .addHeader("Content-Type", "application/json") + .post(body.toRequestBody(jsonMedia)) + .build() + ).execute().use { retry -> + val retryBody = retry.body?.string().orEmpty() + if (retry.isSuccessful) findToken(retryBody) else null + } + } + else -> null + } + if (token != null) AuthHeader("Authorization", "Bearer $token") else null + } + } catch (_: Throwable) { + null + } + } + + private fun findToken(text: String): String? { + return runCatching { + val obj = json.parseToJsonElement(text).jsonObject + val candidates = listOf(obj, obj["data"]?.jsonObject).filterNotNull() + var first: String? = null + outer@ for (root in candidates) { + for (key in listOf("access_token", "accessToken", "token", "api_token", "apiKey")) { + val value = root[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + if (value != null) { + first = value + break@outer + } + } + } + first + }.getOrNull() + } + + /** + * 登录引导专用:个别面板要求先 POST 一次 admin 作用域的 compliance/accept 才能继续登录。 + * 仅作为 login 前置步骤使用,异常被吞、失败不影响余额主链路(余额查询本身走用户级 /v1/usage, + * 不依赖任何 admin 权限)。勿据此误以为集成依赖管理员权限。 + */ + private fun acceptCompliance(base: String) { + try { + okHttpClient.newCall( + Request.Builder() + .url("$base/api/v1/admin/compliance/accept") + .post("{}".toRequestBody(jsonMedia)) + .build() + ).execute().close() + } catch (_: Throwable) { + // 忽略;重试由外层决定 + } + } + + // ---------- 请求 ---------- + + private data class SubApiResponse(val code: Int, val body: String) + + private fun get(base: String, path: String, auth: AuthHeader): SubApiResponse? = + runCatching { + okHttpClient.newCall( + Request.Builder() + .url("$base$path") + .header(auth.name, auth.value) + .get() + .build() + ).execute().let { resp -> + SubApiResponse(resp.code, resp.body?.string().orEmpty()) + } + }.getOrNull() + + private fun mapSubHttpError(code: Int): RepositoryError = when (code) { + 401, 403 -> RepositoryError.InvalidCredential() + 423 -> RepositoryError.ServerError(423) + 429 -> RepositoryError.RateLimited() + in 500..599 -> RepositoryError.ServerError(code) + else -> RepositoryError.Unknown() + } + + // ---------- 解析 ---------- + + private data class UsageStats( + val remaining: Double, + val unit: String?, + val valid: Boolean, + val totalSpent: Double?, + val totalQuota: Double?, + val extras: Map + ) + + private fun parseUsageStats(body: String): UsageStats { + val root = json.parseToJsonElement(body).jsonObject + val top = root["data"]?.jsonObject ?: root + + // 所有余额/用量字段:面板至少给出其一才算「有数据」,否则视为无法展示(isAvailable=false), + // 避免合法 JSON 但无任何余额字段时误显示 $0.00(对齐 WorkBuddy 无数据即不可用的语义)。 + val remainingRaw = top.numberOr("remaining", "balance", "credits", "quota", "amount") + val remaining = remainingRaw ?: 0.0 + val unit = top.scalar("unit", "currency", "symbol") + + // 消费:usage.total.cost / usage.total.actual_cost / total_cost / monthly_spent + val usageObj = top["usage"]?.jsonObject + val totalObj = usageObj?.get("total")?.jsonObject + val totalSpent = (totalObj?.numberOr("cost", "actual_cost")) + ?: top.numberOr("total_cost", "monthly_spent", "spent") + + // 总额度:仅当面板给出明确上限(订阅/配额型)时设置,钱包余额型不设 + val totalQuota = top.numberOr("total_quota", "limit", "quota_limit") + ?.takeIf { it >= 0 } + + val explicitValid = top["isValid"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() + ?: top["valid"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() + val hasData = remainingRaw != null || totalSpent != null || totalQuota != null + val valid = explicitValid ?: hasData + + val extras = linkedMapOf() + top.scalar("planName", "plan_name", "plan")?.let { extras["plan"] = it } + top.scalar("mode")?.let { extras["mode"] = it } + extras["valid"] = valid.toString() + top.scalar("remaining")?.let { extras["remaining"] = it } + top.scalar("unit")?.let { extras["unit"] = it } + (totalObj?.numberOr("cost", "actual_cost"))?.let { extras["total_cost"] = it.toString() } + usageObj?.get("today")?.jsonObject?.numberOr("cost", "actual_cost")?.let { + extras["today_cost"] = it.toString() + } + + // 分模型:全量结构化解析(供筛选/排序),同时保留前 3 个键进 extras 兼容旧 UI。 + val modelStats = top.parseModelStats() + modelStats.take(3).forEach { stat -> + val label = buildString { + if (stat.totalTokens > 0) append(stat.totalTokens).append(" tok") + if (stat.totalTokens > 0 && stat.cost > 0) append(" · ") + if (stat.cost > 0) append("$").append(trimNum(stat.cost)) + }.ifBlank { "-" } + extras["model:" + stat.model] = label + } + + // 每日明细:全量结构化解析(按日期升序,供时间窗口切片)。保留旧 days 键。 + val dailyUsage = top.parseDailyUsage() + if (dailyUsage.isNotEmpty()) extras["days"] = dailyUsage.size.toString() + " d" + + return UsageStats(remaining, unit, valid, totalSpent, totalQuota, extras) + } + + /** + * 独立解析 /v1/usage 响应中的结构化明细(model_stats + daily_usage),供 UI 与单测使用。 + * 响应字段多版本兼容:外层可能是 {data:{...}} 也可能是平铺;解析失败返回空明细。 + */ + internal fun parseUsageDetail(body: String): Sub2UsageDetail { + val ele = runCatching { json.parseToJsonElement(body) }.getOrNull() ?: return Sub2UsageDetail() + val root = ele.jsonObject + val top = root["data"]?.jsonObject ?: root + return Sub2UsageDetail( + modelStats = top.parseModelStats(), + dailyUsage = top.parseDailyUsage() + ) + } + + private fun trimNum(v: Double): String = v.toString().trimEnd('0', '.') + + private fun JsonObject.scalar(vararg keys: String): String? { + for (key in keys) { + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it } + } + return null + } + + /** 仅当字段确实为 JSON 数组时返回(类型错误防御,不抛异常)。 */ + private fun JsonObject.safeArray(key: String): kotlinx.serialization.json.JsonArray? = + runCatching { this[key]?.jsonArray }.getOrNull() + + /** 平铺多候选整数值扫描(防御数字字符串/纯数字两种形态)。 */ + private fun JsonObject.longOr(vararg keys: String): Long? { + for (key in keys) { + this[key]?.let { el -> + (el as? JsonPrimitive)?.contentOrNull?.toLongOrNull()?.let { return it } + } + } + return null + } + + /** 解析 model_stats[]:字段名多版本兼容,行级失败跳过。 */ + private fun JsonObject.parseModelStats(): List = + (safeArray("model_stats") ?: return emptyList()).mapNotNull { el -> + runCatching { + val o = el.jsonObject + val name = o.scalar("model", "model_name", "name", "id") ?: return@mapNotNull null + val input = o.longOr("input_tokens", "prompt_tokens") ?: 0 + val output = o.longOr("output_tokens", "completion_tokens") ?: 0 + Sub2ModelStat( + model = name, + requests = o.longOr("requests", "request_count") ?: 0, + inputTokens = input, + outputTokens = output, + totalTokens = o.longOr("total_tokens") ?: (input + output), + cost = o.numberOr("cost", "actual_cost", "amount") ?: 0.0 + ) + }.getOrNull() + } + + /** 解析 daily_usage[]:date + 请求数/tokens/cost;行级失败跳过;统一按日期升序(防御网关乱序)。 */ + private fun JsonObject.parseDailyUsage(): List = + (safeArray("daily_usage") ?: return emptyList()).mapNotNull { el -> + runCatching { + val o = el.jsonObject + val date = o.scalar("date", "day") ?: return@mapNotNull null + val input = o.longOr("input_tokens", "prompt_tokens") ?: 0 + val output = o.longOr("output_tokens", "completion_tokens") ?: 0 + Sub2DailyUsage( + date = date, + requests = o.longOr("requests") ?: 0, + totalTokens = o.longOr("total_tokens", "tokens") ?: (input + output), + cost = o.numberOr("cost", "actual_cost") ?: 0.0 + ) + }.getOrNull() + }.sortedBy { it.date } + + private fun JsonObject.numberOr(vararg keys: String): Double? { + for (key in keys) { + this[key]?.let { el -> + (el as? JsonPrimitive)?.doubleOrNull?.let { return it } + (el as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.let { return it } + } + } + return null + } + + private fun normalizeBase(url: String): String { + var base = url.trim() + if (base.isBlank()) return "" + if (!base.startsWith("http://") && !base.startsWith("https://")) base = "https://$base" + while (base.endsWith("/")) base = base.dropLast(1) + return base + } + + private data class AuthHeader(val name: String, val value: String) +} 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 new file mode 100644 index 0000000..6dddc7e --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt @@ -0,0 +1,210 @@ +package com.rainy.token.data.repository + +import com.rainy.token.data.cache.BalanceCache +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.service.ServiceType +import java.io.IOException +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * Trae(CN / Intl)余额仓库。 + * + * 认证:官方 IDE/Web 用的 `Authorization: Cloud-IDE-JWT `(RS256,~14 天)。区域只决定域名: + * - CN :POST https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage + * - INTL:POST https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage + * 2026-09 用真实登录态实测(api.trae.cn):**仅带 Authorization + Content-Type 即成功**, + * X-User-Region / X-Device-Id 均非必需(官方 Web 不带),故不再发送,避免设备绑定误伤。 + * + * 真实响应(实测结构): + * ```json + * { + * "is_credits_billing": true, + * "usage_summary": {"consumed_amount":3341.17,"consumption_ratio":0.613,"total_amount":5450}, + * "user_entitlement_pack_list": [ { + * "display_desc":"签到奖励", + * "entitlement_base_info":{"quota":{"credits_limit":150}, ...}, + * "usage":{"credits_amount":142.18}, ... + * } ] + * } + * ``` + * 余额权威口径 = `usage_summary.total_amount - consumed_amount`(>=0);usage_summary 缺失时 + * 才退回包级求和(credits_limit - credits_amount,限流/无限包不计)。字段名防御式扫描。 + */ +@Singleton +class TraeRepository constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, + private val balanceCache: BalanceCache +) { + + private val json = Json { ignoreUnknownKeys = true } + + suspend fun fetchBalance(): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.TRAE) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) + if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + + val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN" + val url = when (regionKey) { + "INTL" -> "https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage" + else -> "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage" + } + val request = Request.Builder() + .url(url) + .addHeader("Authorization", "Cloud-IDE-JWT ${credential.jwt.trim()}") + .addHeader("Content-Type", "application/json") + .post("{}".toRequestBody("application/json".toMediaType())) + .build() + + val body = try { + okHttpClient.newCall(request).execute().use { resp -> + val text = resp.body?.string().orEmpty() + if (!resp.isSuccessful) { + return@withContext Result.failure(mapHttpError(resp.code)) + } + text + } + } catch (e: IOException) { + return@withContext Result.failure(RepositoryError.Network(e)) + } catch (e: Throwable) { + return@withContext Result.failure(RepositoryError.Unknown(e)) + } + + val parsed = runCatching { parseBody(body) } + .getOrElse { e -> + return@withContext Result.failure( + RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "trae 解析失败: ${e.message}") + ) + } + + val balance = ServiceBalance( + service = ServiceType.TRAE, + amount = parsed.amount, + unit = "Credits", + totalQuota = parsed.totalQuota, + extras = parsed.extras + ) + balanceCache.put(ServiceType.TRAE, balance) + credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis())) + Result.success(balance) + } + + /** 解析:usage_summary 权威优先;缺失时回退权益包求和。字段缺失跳过,不抛异常。 */ + private fun parseBody(body: String): ParsedTraeUsage { + val root = json.parseToJsonElement(body).jsonObject + val extras = linkedMapOf() + + root["is_credits_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_credits_billing"] = it } + root["is_dollar_usage_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_dollar_usage_billing"] = it } + root["is_pay_freshman"]?.jsonPrimitive?.contentOrNull?.let { extras["is_new_user"] = it } + + // 1) 权威汇总 + val summary = root["usage_summary"]?.jsonObject + val total = summary?.numberOr("total_amount") + val consumed = summary?.numberOr("consumed_amount") + summary?.let { s -> + s.numberOr("consumed_amount")?.let { extras["consumed_credits"] = trimNum(it) } + s.numberOr("consumption_ratio")?.let { extras["consumption_ratio"] = trimNum(it) } + s.numberOr("total_amount")?.let { extras["total_credits"] = trimNum(it) } + } + + // 2) 权益包明细(前若干条 + 计数),同时累计兜底求和 + var fallbackSum = 0.0 + var packShown = 0 + var packCount = 0 + val packs = root["user_entitlement_pack_list"]?.jsonArray + packs?.forEachIndexed { index, packEl -> + val pack = packEl.jsonObject + val base = pack["entitlement_base_info"]?.jsonObject + val quota = base?.get("quota")?.jsonObject + val name = pack.getString("display_desc") ?: base?.getString("name") ?: "pack[$index]" + val limit = quota?.getNumber("credits_limit")?.toLong() + val used = pack.getNumber("usage", "credits_amount") + ?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble() + packCount++ + when { + limit == null -> { /* 结构变化:跳过 */ } + limit < 0 -> extras["$name.unlimited"] = "true" + else -> { + val remaining = (limit - (used ?: 0.0)).coerceAtLeast(0.0) + fallbackSum += remaining + if (packShown < 8) { + extras["$name.limit"] = limit.toString() + extras["$name.used"] = used?.let { trimNum(it) } ?: "?" + packShown++ + } + } + } + } + if (packCount > 0) extras["pack_count"] = packCount.toString() + + // 3) 金额:汇总优先,缺省回退包级 + val amount = if (total != null && consumed != null) { + (total - consumed).coerceAtLeast(0.0) + } else fallbackSum + val totalQuota = total?.takeIf { it >= 0 } + + return ParsedTraeUsage(amount = amount, totalQuota = totalQuota, extras = extras) + } + + private fun JsonObject.getString(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull + + private fun JsonElement.safeDouble(): Double? = + runCatching { jsonPrimitive.content.toDoubleOrNull() }.getOrNull() + + /** 按路径逐层取数:如 getNumber("usage","credits_amount") 取 this.usage.credits_amount。 */ + private fun JsonObject.getNumber(vararg keys: String): Double? { + if (keys.isEmpty()) return null + var node: JsonObject? = this + for (i in keys.indices) { + val el = node?.get(keys[i]) ?: return null + if (i == keys.lastIndex) return el.safeDouble() + node = runCatching { el.jsonObject }.getOrNull() + } + return null + } + + /** 平铺多候选数值扫描。 */ + private fun JsonObject.numberOr(vararg keys: String): Double? { + for (key in keys) { + this[key]?.let { el -> + (el as? JsonPrimitive)?.doubleOrNull?.let { return it } + (el as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.let { return it } + } + } + return null + } + + private fun mapHttpError(code: Int): RepositoryError = when (code) { + 401, 403 -> RepositoryError.InvalidCredential() + 429 -> RepositoryError.RateLimited() + in 500..599 -> RepositoryError.ServerError(code) + else -> RepositoryError.Unknown() + } + + private fun trimNum(v: Double): String = v.toString().trimEnd('0', '.') + + private data class ParsedTraeUsage( + val amount: Double, + val totalQuota: Double?, + val extras: Map + ) +} 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 new file mode 100644 index 0000000..c238c58 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt @@ -0,0 +1,298 @@ +package com.rainy.token.data.repository + +import com.rainy.token.data.cache.BalanceCache +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.service.ServiceType +import java.io.IOException +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * 腾讯 WorkBuddy 余额仓库。 + * + * 认证:`Authorization: Bearer `;accessToken 过期时用 refreshToken 轮换一次并重试 + * (同 Codex 语义):POST https://copilot.tencent.com/v2/plugin/auth/token/refresh(X-Refresh-Token 头)。 + * + * 资源余额(2026-09 用真实登录态在官网个人中心实测,workbuddy.cn): + * - 主端点:POST https://www.workbuddy.cn/billing/meter/get-user-resource-summary body `{}` + * 真实响应:{"code":0,"data":{"Packages":[{"PackageCode":"TCACA_...","CycleTotalCapacity":"7826", + * "CycleRemainCapacity":"2671.96","CycleUsedCapacity":"5154.04","CycleFrozenCapacity":"0", + * "CapacityUnit":"credits"}, ...], "IsPaidUser":false}} + * - 兜底:POST https://www.codebuddy.cn/v2/billing/meter/get-user-resource(官方 CLI 插件模型, + * ProductCode p_tcaca / Status 0,3),汇总 Packages/Accounts[] 同族字段。 + * 余额 = 各包 CycleRemainCapacity 之和;单位取 CapacityUnit(credits)。 + * 字段名随官方迭代可能变化,解析保持防御式(多候选扫描)。 + */ +@Singleton +class WorkBuddyRepository constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, + private val balanceCache: BalanceCache +) { + + private val json = Json { ignoreUnknownKeys = true } + + private val jsonMedia = "application/json".toMediaType() + + private val codebuddyBody = buildJsonObject { + put("PageNumber", 1) + put("PageSize", 100) + put("ProductCode", "p_tcaca") + put("Status", buildJsonArray { add(JsonPrimitive(0)); add(JsonPrimitive(3)) }) + put("Channel", "P") + put("Unsubmitted", false) + put("Unit", "") + put("QueryResourceId", buildJsonArray { }) + }.toString() + + suspend fun fetchBalance(): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.WORKBUDDY) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) + if (credential !is Credential.WorkBuddyCredential) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + + var access = credential.accessToken + var refresh = credential.refreshToken + // hostMode 0 = workbuddy.cn summary(实测主路径);1 = codebuddy.cn 兜底 + var hostMode = 0 + var attempts = 0 + var lastBody = "" + while (attempts < 3) { + attempts++ + val request = if (hostMode == 0) summaryRequest(access) else codebuddyRequest(access) + val resp = try { + okHttpClient.newCall(request).execute() + } catch (e: IOException) { + return@withContext Result.failure(RepositoryError.Network(e)) + } catch (e: Throwable) { + return@withContext Result.failure(RepositoryError.Unknown(e)) + } + val code = resp.code + val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("") + resp.close() + + if (code in 200..299) { + lastBody = body + break + } + when (code) { + 404 -> { + if (hostMode == 0) { hostMode = 1; continue } + return@withContext Result.failure(mapHttpError(code, body)) + } + 401, 403 -> { + val refreshed = tryRefesh(refresh) ?: return@withContext Result.failure( + RepositoryError.InvalidCredential() + ) + // 暂存轮换后的凭据,随本次成功提交(RefreshWriteSession 机制) + credentialRepository.save( + credential.copy( + accessToken = refreshed.accessToken, + refreshToken = refreshed.refreshToken, + expiresAt = if (refreshed.expiresIn > 0) { + System.currentTimeMillis() + refreshed.expiresIn * 1000 + } else { + credential.expiresAt.coerceAtLeast(System.currentTimeMillis()) + } + ) + ) + access = refreshed.accessToken + refresh = refreshed.refreshToken + continue + } + else -> return@withContext Result.failure(mapHttpError(code, body)) + } + } + + if (lastBody.isBlank()) { + return@withContext Result.failure( + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "workbuddy 响应为空") + ) + } + val parsed = runCatching { parseResource(lastBody) }.getOrElse { e -> + return@withContext Result.failure( + RepositoryError.ParseError( + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, + "workbuddy 资源解析失败: ${e.message}" + ) + ) + } + + val balance = ServiceBalance( + service = ServiceType.WORKBUDDY, + amount = parsed.amount, + unit = parsed.unit ?: "Credits", + monthlySpent = parsed.used.takeIf { it > 0 }, + totalQuota = parsed.size.takeIf { it > 0 }, + isAvailable = parsed.amount > 0 || parsed.size > 0, + extras = parsed.extras + ) + balanceCache.put(ServiceType.WORKBUDDY, balance) + credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis())) + Result.success(balance) + } + + /** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */ + private fun summaryRequest(accessToken: String): Request = + Request.Builder() + .url("https://www.workbuddy.cn/billing/meter/get-user-resource-summary") + .addHeader("Authorization", "Bearer $accessToken") + .addHeader("Content-Type", "application/json") + .post("{}".toRequestBody(jsonMedia)) + .build() + + /** codebuddy.cn 官方 CLI 插件模型(桌面/CLI accessToken 兜底)。 */ + private fun codebuddyRequest(accessToken: String): Request = + Request.Builder() + .url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource") + .addHeader("Authorization", "Bearer $accessToken") + .addHeader("Content-Type", "application/json") + .post(codebuddyBody.toRequestBody(jsonMedia)) + .build() + + /** + * refresh_token 单次轮换:POST token/refresh(X-Refresh-Token 头)。 + * 只读取并返回新凭据;轮换后的提交交给主流程的 save(同一成功才落库)。 + */ + private fun tryRefesh(refreshToken: String): RefreshedWorkBuddy? { + val request = Request.Builder() + .url("https://copilot.tencent.com/v2/plugin/auth/token/refresh") + .addHeader("X-Refresh-Token", refreshToken) + .post("{}".toRequestBody(jsonMedia)) + .build() + return try { + okHttpClient.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) return@use null + val text = resp.body?.string().orEmpty() + val obj = json.parseToJsonElement(text).jsonObject + val access = obj.readToken("access_token", "accessToken") + ?: obj["data"]?.jsonObject?.readToken("access_token", "accessToken") + ?: return@use null + val refresh = obj.readToken("refresh_token", "refreshToken") + ?: obj["data"]?.jsonObject?.readToken("refresh_token", "refreshToken") + ?: refreshToken + val expiresIn = obj["expires_in"]?.jsonPrimitive?.longOrNull ?: 0L + RefreshedWorkBuddy(access, refresh, expiresIn) + } + } catch (_: Throwable) { + null + } + } + + private fun JsonObject.readToken(vararg keys: String): String? { + for (key in keys) { + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it } + } + return null + } + + /** 解析 Packages/Accounts:CycleRemain/Used/TotalCapacity(多候选)。无任何包/数据时抛异常。 */ + private fun parseResource(body: String): ParsedWorkBuddy { + val root = json.parseToJsonElement(body).jsonObject + val data = root["data"]?.jsonObject ?: root + val packages = data["Packages"]?.jsonArray ?: data["Accounts"]?.jsonArray ?: root["Packages"]?.jsonArray + val list = packages?.mapNotNull { it.jsonObject.takeIf { o -> o.isNotEmpty() } } ?: emptyList() + if (list.isEmpty()) { + throw IllegalArgumentException("workbuddy 响应无 Packages/Accounts 数据") + } + + var amount = 0.0 + var used = 0.0 + var size = 0.0 + var frozen = 0.0 + var unitRaw: String? = null + val extras = linkedMapOf() + for (item in list.take(20)) { + val remain = item.number("CycleRemainCapacity", "CycleCapacityRemain", "Remain", "balance") ?: 0.0 + val u = item.number("CycleUsedCapacity", "CycleCapacityUsed", "Used") ?: 0.0 + val s = item.number("CycleTotalCapacity", "CycleCapacitySize", "Size", "Total") ?: 0.0 + val f = item.number("CycleFrozenCapacity", "Frozen") ?: 0.0 + amount += remain + used += u + size += s + frozen += f + unitRaw = unitRaw ?: item.scalar("CapacityUnit", "unit", "currency", "Unit") + val name = item.scalar("ProductName", "PackageCode", "ResourceName", "name", "ResourceId") + if (!name.isNullOrBlank() && extras.size < 3) { + extras[name] = "${trimNum(remain)} ${normalizeUnit(unitRaw) ?: "Credits"}" + } + } + if (list.size > 0) extras["packages"] = list.size.toString() + if (frozen > 0) extras["frozen"] = trimNum(frozen) + data["IsPaidUser"]?.jsonPrimitive?.contentOrNull?.let { extras["is_paid_user"] = it } + data["SubscriptionPackageCode"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + ?.let { extras["subscription"] = "true" } + + return ParsedWorkBuddy( + amount = amount, + used = used, + size = size, + unit = normalizeUnit(unitRaw), + extras = extras + ) + } + + /** 官方字段 credits → 展示单位 Credits;其它原样透传。 */ + private fun normalizeUnit(raw: String?): String? { + if (raw.isNullOrBlank()) return null + return if (raw.equals("credits", ignoreCase = true)) "Credits" else raw + } + + private fun trimNum(v: Double): String = v.toString().trimEnd('0', '.') + + private fun JsonObject.number(vararg keys: String): Double? { + for (key in keys) { + this[key]?.jsonPrimitive?.doubleOrNull?.let { return it } + this[key]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()?.let { return it } + } + return null + } + + private fun JsonObject.scalar(vararg keys: String): String? { + for (key in keys) { + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it } + } + return null + } + + private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) { + 401, 403 -> RepositoryError.InvalidCredential() + 429 -> RepositoryError.RateLimited() + 423 -> RepositoryError.ServerError(code) + in 500..599 -> RepositoryError.ServerError(code) + else -> RepositoryError.Unknown() + } + + private data class RefreshedWorkBuddy( + val accessToken: String, + val refreshToken: String, + val expiresIn: Long + ) + + private data class ParsedWorkBuddy( + val amount: Double, + val used: Double, + val size: Double, + val unit: String?, + val extras: Map + ) +} diff --git a/app/src/main/java/com/rainy/token/di/NetworkModule.kt b/app/src/main/java/com/rainy/token/di/NetworkModule.kt index 42302ef..406b161 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -17,6 +17,9 @@ import com.rainy.token.data.repository.CommandCodeUsageRepository import com.rainy.token.data.repository.OpenCodeGoRepository import com.rainy.token.data.repository.OpenCodeUsageRepository import com.rainy.token.data.repository.OllamaRepository +import com.rainy.token.data.repository.Sub2ApiRepository +import com.rainy.token.data.repository.TraeRepository +import com.rainy.token.data.repository.WorkBuddyRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -176,6 +179,39 @@ object NetworkModule { balanceCache: BalanceCache ): OllamaRepository = OllamaRepository(okHttpClient, credentialRepository, balanceCache) + /** + * Trae 仓库:Cloud-IDE-JWT 认证 + REST。 + */ + @Provides + @Singleton + fun provideTraeRepository( + okHttpClient: OkHttpClient, + credentialRepository: CredentialRepository, + balanceCache: BalanceCache + ): TraeRepository = TraeRepository(okHttpClient, credentialRepository, balanceCache) + + /** + * WorkBuddy 仓库:OAuth Bearer + get-user-resource。 + */ + @Provides + @Singleton + fun provideWorkBuddyRepository( + okHttpClient: OkHttpClient, + credentialRepository: CredentialRepository, + balanceCache: BalanceCache + ): WorkBuddyRepository = WorkBuddyRepository(okHttpClient, credentialRepository, balanceCache) + + /** + * Sub2API 仓库:自托管实例(凭据里的 baseUrl)+ 管理面板 API。 + */ + @Provides + @Singleton + fun provideSub2ApiRepository( + okHttpClient: OkHttpClient, + credentialRepository: CredentialRepository, + balanceCache: BalanceCache + ): Sub2ApiRepository = Sub2ApiRepository(okHttpClient, credentialRepository, balanceCache) + /** 余额缓存 DataStore(计划 7.1) */ @Provides @Singleton diff --git a/app/src/main/java/com/rainy/token/domain/model/Credential.kt b/app/src/main/java/com/rainy/token/domain/model/Credential.kt index 44c82ab..b062dfc 100644 --- a/app/src/main/java/com/rainy/token/domain/model/Credential.kt +++ b/app/src/main/java/com/rainy/token/domain/model/Credential.kt @@ -62,6 +62,55 @@ sealed class Credential { val expiresAt: Long, override val lastVerifiedAt: Long = 0L ) : Credential() + + /** + * 用于 Trae(CN/Intl):官方 IDE 扩展使用的 Cloud-IDE-JWT。 + * 单个 JWT(~14 天 RS256),配 [region] 决定域名与请求头。 + */ + @Serializable + data class TraeCredential( + override val service: ServiceType, + /** `Authorization: Cloud-IDE-JWT ` 的 JWT 值 */ + val jwt: String, + /** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai) */ + val region: String = "CN", + override val lastVerifiedAt: Long = 0L + ) : Credential() + + /** + * 用于腾讯 WorkBuddy(AI 办公 Agent,积分与 CodeBuddy 同体系)。 + * 复用 Codex 的 OAuth Bearer 模式:accessToken + refreshToken(单次轮换)。 + */ + @Serializable + data class WorkBuddyCredential( + override val service: ServiceType, + val accessToken: String, + val refreshToken: String, + /** 过期时间(epoch millis),0=未知 */ + val expiresAt: Long = 0L, + override val lastVerifiedAt: Long = 0L + ) : Credential() + + /** + * 用于 Sub2API(自建 mid 层/聚合面板,任意用户填自己的实例地址)。 + * 走【用户级】查询:普通用户填实例分配给他的 sk- API Key 即可查余额,无需管理员。 + * 实测(xxcsn.site):GET {base}/v1/usage + Authorization: Bearer + * 返回 remaining/balance/unit/usage.total.cost/model_stats 等。 + * 无 API Key 时可用面板邮箱+密码登录换取 Bearer token 作为备选。 + */ + @Serializable + data class Sub2ApiCredential( + override val service: ServiceType, + /** 自托管实例基址,如 https://api.example.com(自动去尾斜杠) */ + val baseUrl: String, + /** 面板登录邮箱(无 API Key 的备选认证) */ + val email: String, + /** 面板登录密码(无 API Key 的备选认证) */ + val password: String, + /** 用户级 API Key(sk-…),存在时优先于 email/password 认证 */ + val apiKey: String? = null, + override val lastVerifiedAt: Long = 0L + ) : Credential() } /** diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt index 654d686..2dc191c 100644 --- a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt +++ b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt @@ -10,6 +10,9 @@ enum class FetchMethod { /** WebView 登录后抓取 HTML/内部 JSON API */ WEBVIEW_SCRAPER, + /** 用户自托管实例的 REST API(地址在凭据里,如 Sub2API) */ + SELF_HOSTED_REST, + /** 完全手动输入(已降级) */ MANUAL } @@ -24,6 +27,8 @@ data class ServiceConfig( val loginUrl: String, /** 余额/配额的展示单位(如 "¥" / "$" / "Credits" / "requests") */ val displayUnit: String, + /** 外部操作页 URL(如 DeepSeek 充值页),App 内 WebView 打开,可空 */ + val externalUrl: String? = null, /** 触发降级的连续失败次数阈值(计划 5.4 / 6.3) */ val degradationFailureThreshold: Int = 3, /** Cookie 有效期 < 该值则触发降级(小时) */ @@ -40,7 +45,9 @@ object ServiceConfigProvider { type = ServiceType.DEEPSEEK, method = FetchMethod.REST_API, loginUrl = "", // REST 模式不需要登录 URL - displayUnit = "¥" + displayUnit = "¥", + // App 内 WebView 打开的官方充值页 + externalUrl = "https://platform.deepseek.com/top_up" ), ServiceType.OPENCODE_GO to ServiceConfig( type = ServiceType.OPENCODE_GO, @@ -66,6 +73,25 @@ object ServiceConfigProvider { method = FetchMethod.WEBVIEW_SCRAPER, loginUrl = "https://ollama.com/settings", displayUnit = "%" + ), + ServiceType.TRAE to ServiceConfig( + type = ServiceType.TRAE, + method = FetchMethod.REST_API, + loginUrl = "", + displayUnit = "Credits" + ), + ServiceType.WORKBUDDY to ServiceConfig( + type = ServiceType.WORKBUDDY, + method = FetchMethod.REST_API, + loginUrl = "", + displayUnit = "Credits" + ), + ServiceType.SUB2API to ServiceConfig( + type = ServiceType.SUB2API, + // 地址在用户填写的凭据里,按自托管实例调用 + method = FetchMethod.SELF_HOSTED_REST, + loginUrl = "", + displayUnit = "$" ) ) diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt index 3adad0a..91f4a42 100644 --- a/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt +++ b/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt @@ -18,7 +18,10 @@ enum class ServiceType( COMMANDCODE_GO("CommandCode", "commandcode_go"), DEEPSEEK("DeepSeek", "deepseek"), CODEX("Codex / ChatGPT", "codex"), - OLLAMA("Ollama", "ollama"); + OLLAMA("Ollama", "ollama"), + TRAE("Trae", "trae"), + WORKBUDDY("WorkBuddy", "workbuddy"), + SUB2API("Sub2API", "sub2api"); companion object { fun fromStorageKey(key: String): ServiceType? = diff --git a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt index 0fb8b2b..54366b4 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt @@ -8,6 +8,9 @@ import com.rainy.token.data.repository.OllamaRepository import com.rainy.token.data.repository.OpenCodeGoRepository import com.rainy.token.data.repository.RefreshWriteSession import com.rainy.token.data.repository.RepositoryError +import com.rainy.token.data.repository.Sub2ApiRepository +import com.rainy.token.data.repository.TraeRepository +import com.rainy.token.data.repository.WorkBuddyRepository import com.rainy.token.data.repository.retryOnTransientError import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.model.TriggerSummary @@ -30,7 +33,10 @@ class RefreshBalanceUseCase @Inject constructor( private val openCodeGoRepositoryProvider: Provider, private val commandCodeGoRepositoryProvider: Provider, private val codexRepositoryProvider: Provider, - private val ollamaRepositoryProvider: Provider + private val ollamaRepositoryProvider: Provider, + private val traeRepositoryProvider: Provider, + private val workBuddyRepositoryProvider: Provider, + private val sub2ApiRepositoryProvider: Provider ) { suspend operator fun invoke(service: ServiceType): Result = withCredentialSession(service) { @@ -50,6 +56,15 @@ class RefreshBalanceUseCase @Inject constructor( ServiceType.OLLAMA -> retryOnTransientError { ollamaRepositoryProvider.get().fetchBalance() } + ServiceType.TRAE -> retryOnTransientError { + traeRepositoryProvider.get().fetchBalance() + } + ServiceType.WORKBUDDY -> retryOnTransientError { + workBuddyRepositoryProvider.get().fetchBalance() + } + ServiceType.SUB2API -> retryOnTransientError { + sub2ApiRepositoryProvider.get().fetchBalance() + } } } diff --git a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt index e402796..e046271 100644 --- a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt +++ b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt @@ -13,8 +13,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.material3.Text import com.rainy.token.R import com.rainy.token.domain.service.ServiceType @@ -117,5 +119,56 @@ fun ServiceIcon( ) } } + ServiceType.TRAE -> { + LetterIcon( + letter = "T", + background = Color(0xFF20242E), + modifier = modifier, + size = size + ) + } + ServiceType.WORKBUDDY -> { + LetterIcon( + letter = "W", + background = Color(0xFF3339FF), + modifier = modifier, + size = size + ) + } + ServiceType.SUB2API -> { + LetterIcon( + letter = "S", + background = Color(0xFF6C5CE7), + modifier = modifier, + size = size + ) + } + } +} + +/** + * 品牌色字母占位图标。正式 logo(Trae / WorkBuddy / Sub2API)落地后 + * 在此替换为 drawable 分支(同 DeepSeek 模式)。 + */ +@Composable +private fun LetterIcon( + letter: String, + background: Color, + modifier: Modifier, + size: Int +) { + Box( + modifier = modifier + .size(size.dp) + .clip(RoundedCornerShape(10.dp)) + .background(background), + contentAlignment = Alignment.Center + ) { + Text( + text = letter, + color = Color.White, + fontSize = (size * 0.5f).sp, + fontWeight = FontWeight.Bold + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index aee9edb..48e6b0c 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -115,7 +115,38 @@ fun CredentialEditScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - if (uiState.isApiKeyService) { + if (service == ServiceType.TRAE) { + TraeCredentialForm( + jwt = uiState.traeJwt, + region = uiState.traeRegion, + hasExisting = uiState.hasExisting, + onJwtChange = viewModel::updateTraeJwt, + onRegionChange = viewModel::updateTraeRegion, + onSave = viewModel::saveTraeCredential + ) + } else if (service == ServiceType.WORKBUDDY) { + WorkBuddyCredentialForm( + access = uiState.workBuddyAccess, + refresh = uiState.workBuddyRefresh, + hasExisting = uiState.hasExisting, + onAccessChange = viewModel::updateWorkBuddyAccess, + onRefreshChange = viewModel::updateWorkBuddyRefresh, + onSave = viewModel::saveWorkBuddyCredential + ) + } else if (service == ServiceType.SUB2API) { + Sub2ApiCredentialForm( + baseUrl = uiState.sub2BaseUrl, + email = uiState.sub2Email, + password = uiState.sub2Password, + apiKey = uiState.sub2ApiKey, + hasExisting = uiState.hasExisting, + onBaseUrlChange = viewModel::updateSub2BaseUrl, + onEmailChange = viewModel::updateSub2Email, + onPasswordChange = viewModel::updateSub2Password, + onApiKeyChange = viewModel::updateSub2ApiKey, + onSave = viewModel::saveSub2ApiCredential + ) + } else if (uiState.isApiKeyService) { if (service == ServiceType.COMMANDCODE_GO) { CommandCodeGoForm( apiKey = uiState.apiKey, @@ -676,6 +707,166 @@ private fun ManualCookieForm( } } +@Composable +private fun TraeCredentialForm( + jwt: String, + region: String, + hasExisting: Boolean, + onJwtChange: (String) -> Unit, + onRegionChange: (String) -> Unit, + onSave: () -> Unit +) { + Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(R.string.credential_hint_trae), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + OutlinedTextField( + value = jwt, + onValueChange = onJwtChange, + label = { Text(stringResource(R.string.field_trae_jwt)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.field_trae_region), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RegionButton( + label = stringResource(R.string.option_region_cn), + selected = region.uppercase() != "INTL", + onSelect = { onRegionChange("CN") } + ) + RegionButton( + label = stringResource(R.string.option_region_intl), + selected = region.uppercase() == "INTL", + onSelect = { onRegionChange("INTL") } + ) + } + Button(onClick = onSave, enabled = jwt.isNotBlank(), modifier = Modifier.fillMaxWidth()) { + Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save)) + } +} + +@Composable +private fun RegionButton(label: String, selected: Boolean, onSelect: () -> Unit) { + OutlinedButton(onClick = onSelect) { + Text( + text = label, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +private fun WorkBuddyCredentialForm( + access: String, + refresh: String, + hasExisting: Boolean, + onAccessChange: (String) -> Unit, + onRefreshChange: (String) -> Unit, + onSave: () -> Unit +) { + Text(text = stringResource(R.string.credential_title_workbuddy), style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(R.string.credential_hint_workbuddy), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + OutlinedTextField( + value = access, + onValueChange = onAccessChange, + label = { Text(stringResource(R.string.field_workbuddy_access)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = refresh, + onValueChange = onRefreshChange, + label = { Text(stringResource(R.string.field_workbuddy_refresh)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Button(onClick = onSave, enabled = access.isNotBlank() && refresh.isNotBlank(), modifier = Modifier.fillMaxWidth()) { + Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save)) + } +} + +@Composable +private fun Sub2ApiCredentialForm( + baseUrl: String, + email: String, + password: String, + apiKey: String, + hasExisting: Boolean, + onBaseUrlChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onApiKeyChange: (String) -> Unit, + onSave: () -> Unit +) { + Text(text = stringResource(R.string.credential_title_sub2api), style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(R.string.credential_hint_sub2api), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + OutlinedTextField( + value = baseUrl, + onValueChange = onBaseUrlChange, + label = { Text(stringResource(R.string.field_sub2_base_url)) }, + placeholder = { Text(stringResource(R.string.placeholder_sub2_base_url)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.sub2_auth_header), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline + ) + OutlinedTextField( + value = apiKey, + onValueChange = onApiKeyChange, + label = { Text(stringResource(R.string.field_sub2_api_key)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.sub2_or_account), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline + ) + OutlinedTextField( + value = email, + onValueChange = onEmailChange, + label = { Text(stringResource(R.string.field_sub2_email)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + label = { Text(stringResource(R.string.field_sub2_password)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = onSave, + enabled = baseUrl.isNotBlank() && (apiKey.isNotBlank() || (email.isNotBlank() && password.isNotBlank())), + modifier = Modifier.fillMaxWidth() + ) { + Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save)) + } +} + private fun copyToClipboard(context: Context, text: String) { val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm.setPrimaryClip(ClipData.newPlainText("login_url", text)) 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 8c18888..0251c8c 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 @@ -92,6 +92,14 @@ class CredentialEditViewModel @Inject constructor( } else "", ollamaCookie = (existing as? Credential.SessionCredential)?.ollamaCookie.orEmpty(), triggerApiKey = (existing as? Credential.SessionCredential)?.apiKey.orEmpty(), + traeJwt = (existing as? Credential.TraeCredential)?.jwt.orEmpty(), + traeRegion = (existing as? Credential.TraeCredential)?.region ?: "CN", + workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(), + workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(), + sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(), + sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(), + sub2Password = (existing as? Credential.Sub2ApiCredential)?.password.orEmpty(), + sub2ApiKey = (existing as? Credential.Sub2ApiCredential)?.apiKey.orEmpty(), hasExisting = existing != null ) } @@ -240,6 +248,123 @@ class CredentialEditViewModel @Inject constructor( } } + // ---------- Trae ---------- + + fun updateTraeJwt(value: String) { + _uiState.update { it.copy(traeJwt = value) } + } + + fun updateTraeRegion(value: String) { + _uiState.update { it.copy(traeRegion = value) } + } + + fun saveTraeCredential() { + val type = serviceType ?: return + val current = _uiState.value + val jwt = current.traeJwt.trim() + if (jwt.isBlank()) { + _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } + return + } + viewModelScope.launch { + val existing = credentialRepository.get(type) as? Credential.TraeCredential + credentialRepository.save( + (existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy( + jwt = jwt, + region = if (current.traeRegion.uppercase() == "INTL") "INTL" else "CN", + lastVerifiedAt = existing?.lastVerifiedAt ?: 0L + ) + ) + _uiState.update { + it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved)) + } + } + } + + // ---------- WorkBuddy ---------- + + fun updateWorkBuddyAccess(value: String) { + _uiState.update { it.copy(workBuddyAccess = value) } + } + + fun updateWorkBuddyRefresh(value: String) { + _uiState.update { it.copy(workBuddyRefresh = value) } + } + + fun saveWorkBuddyCredential() { + val type = serviceType ?: return + val current = _uiState.value + val access = current.workBuddyAccess.trim() + val refresh = current.workBuddyRefresh.trim() + if (access.isBlank() || refresh.isBlank()) { + _uiState.update { it.copy(message = UiText.Resource(R.string.error_workbuddy_tokens_required)) } + return + } + viewModelScope.launch { + val existing = credentialRepository.get(type) as? Credential.WorkBuddyCredential + credentialRepository.save( + (existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh)) + .copy( + accessToken = access, + refreshToken = refresh, + expiresAt = existing?.expiresAt ?: 0L, + lastVerifiedAt = existing?.lastVerifiedAt ?: 0L + ) + ) + _uiState.update { + it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved)) + } + } + } + + // ---------- Sub2API ---------- + + fun updateSub2BaseUrl(value: String) { + _uiState.update { it.copy(sub2BaseUrl = value) } + } + + fun updateSub2Email(value: String) { + _uiState.update { it.copy(sub2Email = value) } + } + + fun updateSub2Password(value: String) { + _uiState.update { it.copy(sub2Password = value) } + } + + fun updateSub2ApiKey(value: String) { + _uiState.update { it.copy(sub2ApiKey = value) } + } + + fun saveSub2ApiCredential() { + val type = serviceType ?: return + val current = _uiState.value + val base = current.sub2BaseUrl.trim() + val apiKey = current.sub2ApiKey.trim() + val email = current.sub2Email.trim() + val password = current.sub2Password.trim() + // 实例地址必填;认证需 API Key,或(邮箱+密码)二选一路径 + if (base.isBlank() || (apiKey.isBlank() && (email.isBlank() || password.isBlank()))) { + _uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) } + return + } + viewModelScope.launch { + val existing = credentialRepository.get(type) as? Credential.Sub2ApiCredential + credentialRepository.save( + (existing ?: Credential.Sub2ApiCredential(service = type, baseUrl = base, email = email, password = password)) + .copy( + baseUrl = base, + email = email, + password = password, + apiKey = apiKey, + lastVerifiedAt = existing?.lastVerifiedAt ?: 0L + ) + ) + _uiState.update { + it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved)) + } + } + } + /** 把 API Key 缩成 'sk-a***xyz' 这种形式,前 4 后 4,中间用 *** 代替。 */ private fun maskedKeyPreview(key: String): UiText { if (key.length <= 8) { @@ -572,8 +697,17 @@ class CredentialEditViewModel @Inject constructor( authCookie = "", workspaceId = "", cookieCount = 0, + codexAuthJson = "", ollamaCookie = "", - triggerApiKey = "" + triggerApiKey = "", + traeJwt = "", + traeRegion = "CN", + workBuddyAccess = "", + workBuddyRefresh = "", + sub2BaseUrl = "", + sub2Email = "", + sub2Password = "", + sub2ApiKey = "" ) } } @@ -651,5 +785,16 @@ data class CredentialEditUiState( val ollamaCookie: String = "", /** OCGO / Ollama 的一键激活用量 API Key */ val triggerApiKey: String = "", + /** Trae:Cloud-IDE-JWT + 区域 */ + val traeJwt: String = "", + val traeRegion: String = "CN", + /** WorkBuddy:OAuth tokens */ + val workBuddyAccess: String = "", + val workBuddyRefresh: String = "", + /** Sub2API:自托管实例连接 */ + val sub2BaseUrl: String = "", + val sub2Email: String = "", + val sub2Password: String = "", + val sub2ApiKey: String = "", val message: UiText? = null )