feat: 新增 Trae / WorkBuddy / Sub2API 三家供应商接入与凭据管理
This commit is contained in:
parent
3bef8cc3f1
commit
e4cd9a36f6
@ -350,6 +350,23 @@ class CredentialRepository @Inject constructor(
|
|||||||
field("refreshToken", credential.refreshToken)
|
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)
|
return sha256(material)
|
||||||
@ -396,6 +413,24 @@ class CredentialRepository @Inject constructor(
|
|||||||
field("refreshToken", credential.refreshToken)
|
field("refreshToken", credential.refreshToken)
|
||||||
field("accountId", credential.accountId)
|
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)
|
return sha256(material)
|
||||||
|
|||||||
@ -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 <API Key>`(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<ServiceBalance> = 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<String, String>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<String, String>()
|
||||||
|
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<Sub2ModelStat> =
|
||||||
|
(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<Sub2DailyUsage> =
|
||||||
|
(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)
|
||||||
|
}
|
||||||
@ -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 <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<ServiceBalance> = 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<String, String>()
|
||||||
|
|
||||||
|
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<String, String>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>`;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<ServiceBalance> = 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<String, String>()
|
||||||
|
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<String, String>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -17,6 +17,9 @@ import com.rainy.token.data.repository.CommandCodeUsageRepository
|
|||||||
import com.rainy.token.data.repository.OpenCodeGoRepository
|
import com.rainy.token.data.repository.OpenCodeGoRepository
|
||||||
import com.rainy.token.data.repository.OpenCodeUsageRepository
|
import com.rainy.token.data.repository.OpenCodeUsageRepository
|
||||||
import com.rainy.token.data.repository.OllamaRepository
|
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.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@ -176,6 +179,39 @@ object NetworkModule {
|
|||||||
balanceCache: BalanceCache
|
balanceCache: BalanceCache
|
||||||
): OllamaRepository = OllamaRepository(okHttpClient, credentialRepository, 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) */
|
/** 余额缓存 DataStore(计划 7.1) */
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@ -62,6 +62,55 @@ sealed class Credential {
|
|||||||
val expiresAt: Long,
|
val expiresAt: Long,
|
||||||
override val lastVerifiedAt: Long = 0L
|
override val lastVerifiedAt: Long = 0L
|
||||||
) : Credential()
|
) : 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>` 的 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 <apiKey>
|
||||||
|
* 返回 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -10,6 +10,9 @@ enum class FetchMethod {
|
|||||||
/** WebView 登录后抓取 HTML/内部 JSON API */
|
/** WebView 登录后抓取 HTML/内部 JSON API */
|
||||||
WEBVIEW_SCRAPER,
|
WEBVIEW_SCRAPER,
|
||||||
|
|
||||||
|
/** 用户自托管实例的 REST API(地址在凭据里,如 Sub2API) */
|
||||||
|
SELF_HOSTED_REST,
|
||||||
|
|
||||||
/** 完全手动输入(已降级) */
|
/** 完全手动输入(已降级) */
|
||||||
MANUAL
|
MANUAL
|
||||||
}
|
}
|
||||||
@ -24,6 +27,8 @@ data class ServiceConfig(
|
|||||||
val loginUrl: String,
|
val loginUrl: String,
|
||||||
/** 余额/配额的展示单位(如 "¥" / "$" / "Credits" / "requests") */
|
/** 余额/配额的展示单位(如 "¥" / "$" / "Credits" / "requests") */
|
||||||
val displayUnit: String,
|
val displayUnit: String,
|
||||||
|
/** 外部操作页 URL(如 DeepSeek 充值页),App 内 WebView 打开,可空 */
|
||||||
|
val externalUrl: String? = null,
|
||||||
/** 触发降级的连续失败次数阈值(计划 5.4 / 6.3) */
|
/** 触发降级的连续失败次数阈值(计划 5.4 / 6.3) */
|
||||||
val degradationFailureThreshold: Int = 3,
|
val degradationFailureThreshold: Int = 3,
|
||||||
/** Cookie 有效期 < 该值则触发降级(小时) */
|
/** Cookie 有效期 < 该值则触发降级(小时) */
|
||||||
@ -40,7 +45,9 @@ object ServiceConfigProvider {
|
|||||||
type = ServiceType.DEEPSEEK,
|
type = ServiceType.DEEPSEEK,
|
||||||
method = FetchMethod.REST_API,
|
method = FetchMethod.REST_API,
|
||||||
loginUrl = "", // REST 模式不需要登录 URL
|
loginUrl = "", // REST 模式不需要登录 URL
|
||||||
displayUnit = "¥"
|
displayUnit = "¥",
|
||||||
|
// App 内 WebView 打开的官方充值页
|
||||||
|
externalUrl = "https://platform.deepseek.com/top_up"
|
||||||
),
|
),
|
||||||
ServiceType.OPENCODE_GO to ServiceConfig(
|
ServiceType.OPENCODE_GO to ServiceConfig(
|
||||||
type = ServiceType.OPENCODE_GO,
|
type = ServiceType.OPENCODE_GO,
|
||||||
@ -66,6 +73,25 @@ object ServiceConfigProvider {
|
|||||||
method = FetchMethod.WEBVIEW_SCRAPER,
|
method = FetchMethod.WEBVIEW_SCRAPER,
|
||||||
loginUrl = "https://ollama.com/settings",
|
loginUrl = "https://ollama.com/settings",
|
||||||
displayUnit = "%"
|
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 = "$"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,10 @@ enum class ServiceType(
|
|||||||
COMMANDCODE_GO("CommandCode", "commandcode_go"),
|
COMMANDCODE_GO("CommandCode", "commandcode_go"),
|
||||||
DEEPSEEK("DeepSeek", "deepseek"),
|
DEEPSEEK("DeepSeek", "deepseek"),
|
||||||
CODEX("Codex / ChatGPT", "codex"),
|
CODEX("Codex / ChatGPT", "codex"),
|
||||||
OLLAMA("Ollama", "ollama");
|
OLLAMA("Ollama", "ollama"),
|
||||||
|
TRAE("Trae", "trae"),
|
||||||
|
WORKBUDDY("WorkBuddy", "workbuddy"),
|
||||||
|
SUB2API("Sub2API", "sub2api");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromStorageKey(key: String): ServiceType? =
|
fun fromStorageKey(key: String): ServiceType? =
|
||||||
|
|||||||
@ -8,6 +8,9 @@ import com.rainy.token.data.repository.OllamaRepository
|
|||||||
import com.rainy.token.data.repository.OpenCodeGoRepository
|
import com.rainy.token.data.repository.OpenCodeGoRepository
|
||||||
import com.rainy.token.data.repository.RefreshWriteSession
|
import com.rainy.token.data.repository.RefreshWriteSession
|
||||||
import com.rainy.token.data.repository.RepositoryError
|
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.data.repository.retryOnTransientError
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.model.TriggerSummary
|
import com.rainy.token.domain.model.TriggerSummary
|
||||||
@ -30,7 +33,10 @@ class RefreshBalanceUseCase @Inject constructor(
|
|||||||
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
|
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
|
||||||
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
|
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
|
||||||
private val codexRepositoryProvider: Provider<CodexRepository>,
|
private val codexRepositoryProvider: Provider<CodexRepository>,
|
||||||
private val ollamaRepositoryProvider: Provider<OllamaRepository>
|
private val ollamaRepositoryProvider: Provider<OllamaRepository>,
|
||||||
|
private val traeRepositoryProvider: Provider<TraeRepository>,
|
||||||
|
private val workBuddyRepositoryProvider: Provider<WorkBuddyRepository>,
|
||||||
|
private val sub2ApiRepositoryProvider: Provider<Sub2ApiRepository>
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> =
|
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> =
|
||||||
withCredentialSession(service) {
|
withCredentialSession(service) {
|
||||||
@ -50,6 +56,15 @@ class RefreshBalanceUseCase @Inject constructor(
|
|||||||
ServiceType.OLLAMA -> retryOnTransientError {
|
ServiceType.OLLAMA -> retryOnTransientError {
|
||||||
ollamaRepositoryProvider.get().fetchBalance()
|
ollamaRepositoryProvider.get().fetchBalance()
|
||||||
}
|
}
|
||||||
|
ServiceType.TRAE -> retryOnTransientError {
|
||||||
|
traeRepositoryProvider.get().fetchBalance()
|
||||||
|
}
|
||||||
|
ServiceType.WORKBUDDY -> retryOnTransientError {
|
||||||
|
workBuddyRepositoryProvider.get().fetchBalance()
|
||||||
|
}
|
||||||
|
ServiceType.SUB2API -> retryOnTransientError {
|
||||||
|
sub2ApiRepositoryProvider.get().fetchBalance()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,8 +13,10 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.domain.service.ServiceType
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -115,7 +115,38 @@ fun CredentialEditScreen(
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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) {
|
if (service == ServiceType.COMMANDCODE_GO) {
|
||||||
CommandCodeGoForm(
|
CommandCodeGoForm(
|
||||||
apiKey = uiState.apiKey,
|
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) {
|
private fun copyToClipboard(context: Context, text: String) {
|
||||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
cm.setPrimaryClip(ClipData.newPlainText("login_url", text))
|
cm.setPrimaryClip(ClipData.newPlainText("login_url", text))
|
||||||
|
|||||||
@ -92,6 +92,14 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
} else "",
|
} else "",
|
||||||
ollamaCookie = (existing as? Credential.SessionCredential)?.ollamaCookie.orEmpty(),
|
ollamaCookie = (existing as? Credential.SessionCredential)?.ollamaCookie.orEmpty(),
|
||||||
triggerApiKey = (existing as? Credential.SessionCredential)?.apiKey.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
|
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,中间用 *** 代替。 */
|
/** 把 API Key 缩成 'sk-a***xyz' 这种形式,前 4 后 4,中间用 *** 代替。 */
|
||||||
private fun maskedKeyPreview(key: String): UiText {
|
private fun maskedKeyPreview(key: String): UiText {
|
||||||
if (key.length <= 8) {
|
if (key.length <= 8) {
|
||||||
@ -572,8 +697,17 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
authCookie = "",
|
authCookie = "",
|
||||||
workspaceId = "",
|
workspaceId = "",
|
||||||
cookieCount = 0,
|
cookieCount = 0,
|
||||||
|
codexAuthJson = "",
|
||||||
ollamaCookie = "",
|
ollamaCookie = "",
|
||||||
triggerApiKey = ""
|
triggerApiKey = "",
|
||||||
|
traeJwt = "",
|
||||||
|
traeRegion = "CN",
|
||||||
|
workBuddyAccess = "",
|
||||||
|
workBuddyRefresh = "",
|
||||||
|
sub2BaseUrl = "",
|
||||||
|
sub2Email = "",
|
||||||
|
sub2Password = "",
|
||||||
|
sub2ApiKey = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -651,5 +785,16 @@ data class CredentialEditUiState(
|
|||||||
val ollamaCookie: String = "",
|
val ollamaCookie: String = "",
|
||||||
/** OCGO / Ollama 的一键激活用量 API Key */
|
/** OCGO / Ollama 的一键激活用量 API Key */
|
||||||
val triggerApiKey: String = "",
|
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
|
val message: UiText? = null
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user