fix(trae): 支持回调仅带 refreshToken 时自动兑换 JWT

- 登录回调不再只认 userJwt;老流程只回传 refreshToken 时调用 ExchangeToken 兑换 Cloud-IDE-JWT
- 兑换失败给出明确提示,不再永远停在 Authenticating
This commit is contained in:
Liuxinyu176 2026-10-09 17:21:48 +08:00
parent 57bd49f470
commit 9db3e01d49
5 changed files with 109 additions and 39 deletions

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token"
minSdk = 31
targetSdk = 35
versionCode = 33
versionName = "1.7.13"
versionCode = 34
versionName = "1.7.14"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -195,52 +195,76 @@ class WebViewLoginViewModel @Inject constructor(
DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败")
return
}
// 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志)
// 只记录参数名(不含 token 值,避免把 token 明文落调试日志)
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}")
// Trae 回调中参数名可能为 userJwt 或 user_jwt,值是 JSON 字符串
// Trae 回调可能走两套:
// 1) userJwt/user_jwt 直接带 Token+RefreshToken(正常流程)
// 2) 只带 refreshToken(老流程),需要调用 ExchangeToken 兑换 Cloud-IDE-JWT
val userJwtRaw = parsed.queryParameter("userJwt")
?: parsed.queryParameter("user_jwt")
?: run {
DebugLog.w("TraeLogin", "onTraeCallback: 无 userJwt/user_jwt 参数")
return
}
val userJwt = try {
Json.parseToJsonElement(userJwtRaw).jsonObject
} catch (e: Exception) {
DebugLog.w("TraeLogin", "onTraeCallback: userJwt JSON 解析失败: ${e.message}")
return
}
val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
if (token.isNullOrBlank()) {
DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但 userJwt 无 Token(userJwtKeys=${userJwt.keys})")
return
}
DebugLog.i("TraeLogin", "onTraeCallback: userJwt 解析成功 token非空=${token.isNotBlank()}")
// 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
// 注意不能用 userJwt.ClientID——那是固定客户端标识(en1oxy7wnw8j9n),所有
// 用户相同,作 x-device-id 会被服务端按设备风控拒绝(「参与用户太多」)。
val deviceId = traeDeviceId()
// 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
val userId = parsed.queryParameter("userInfo")?.let { raw ->
runCatching {
val u = Json.parseToJsonElement(raw).jsonObject
u["userId"]?.jsonPrimitive?.contentOrNull
?: u["user_id"]?.jsonPrimitive?.contentOrNull
?: u["id"]?.jsonPrimitive?.contentOrNull
?: u["accountId"]?.jsonPrimitive?.contentOrNull
}.getOrNull()
}
DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (userId.isNullOrBlank()) "null" else "ok"}")
?: ""
viewModelScope.launch {
// 刚成功登录的凭据视为已验证:lastVerifiedAt 记为当前时间,
// 否则首页/列表会显示"未验证/未登录"直到进详情页拉余额后才正常
val userJwt = runCatching {
Json.parseToJsonElement(userJwtRaw).jsonObject
}.getOrNull() ?: JsonObject(emptyMap())
var token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
?: userJwt["token"]?.jsonPrimitive?.contentOrNull
?: userJwt["AccessToken"]?.jsonPrimitive?.contentOrNull
?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull
var refresh = userJwt["RefreshToken"]?.jsonPrimitive?.contentOrNull
?: userJwt["refreshToken"]?.jsonPrimitive?.contentOrNull
?: parsed.queryParameter("refreshToken")
?: parsed.queryParameter("refresh_token")
?: parsed.queryParameter("data")
val clientId = userJwt["ClientID"]?.jsonPrimitive?.contentOrNull
?: userJwt["clientId"]?.jsonPrimitive?.contentOrNull
?: parsed.queryParameter("clientId")
?: parsed.queryParameter("client_id")
?: "en1oxy7wnw8j9n"
// 老流程:回调只有 refreshToken,先兑换 Cloud-IDE-JWT
if (token.isNullOrBlank() && !refresh.isNullOrBlank()) {
val exchangeHost = parsed.queryParameter("host")?.takeIf { it.startsWith("http") }
?: if (region == "INTL") "https://a0ai-api-sg.byteintlapi.com" else "https://api.trae.cn"
val exchanged = exchangeTraeRefreshToken(refresh, clientId, exchangeHost)
if (exchanged != null) {
token = exchanged.token
refresh = exchanged.refreshToken
DebugLog.i("TraeLogin", "onTraeCallback: refreshToken 兑换成功(host=${exchangeHost})")
} else {
DebugLog.w("TraeLogin", "onTraeCallback: refreshToken 兑换失败(host=${exchangeHost})")
}
}
if (token.isNullOrBlank()) {
DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但无 Token(userJwtKeys=${userJwt.keys})")
_uiState.update {
it.copy(error = UiText.Resource(R.string.error_login_token_missing))
}
return@launch
}
// 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
val deviceId = traeDeviceId()
// 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
val parsedUserId = parsed.queryParameter("userInfo")?.let { raw ->
runCatching {
val u = Json.parseToJsonElement(raw).jsonObject
u["userId"]?.jsonPrimitive?.contentOrNull
?: u["user_id"]?.jsonPrimitive?.contentOrNull
?: u["id"]?.jsonPrimitive?.contentOrNull
?: u["accountId"]?.jsonPrimitive?.contentOrNull
}.getOrNull()
}
DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (parsedUserId.isNullOrBlank()) "null" else "ok"}")
val credential = Credential.TraeCredential(
service = ServiceType.TRAE,
jwt = token,
region = if (region == "INTL") "INTL" else "CN",
deviceId = deviceId,
userId = userId?.takeIf { it.isNotBlank() },
userId = parsedUserId?.takeIf { it.isNotBlank() },
lastVerifiedAt = System.currentTimeMillis()
)
val effectiveId = credentialRepository.save(credential, accountId)
@ -251,6 +275,49 @@ class WebViewLoginViewModel @Inject constructor(
}
}
/** 使用 refreshToken 向 Trae OAuth 兑换 Cloud-IDE-JWT(老流程回调没有 userJwt)。 */
private data class TraeExchangeResult(val token: String, val refreshToken: String)
private suspend fun exchangeTraeRefreshToken(
refreshToken: String,
clientId: String,
host: String
): TraeExchangeResult? = withContext(Dispatchers.IO) {
try {
val payload = buildString {
append("{\"ClientID\":\"").append(clientId)
.append("\",\"RefreshToken\":\"").append(refreshToken)
.append("\",\"ClientSecret\":\"-\",\"UserID\":\"\"}")
}
val request = Request.Builder()
.url(host.trimEnd('/') + "/cloudide/api/v3/trae/oauth/ExchangeToken")
.addHeader("Content-Type", "application/json")
.post(payload.toRequestBody("application/json".toMediaTypeOrNull()))
.build()
okHttpClient.newCall(request).execute().use { resp ->
val body = resp.body?.string().orEmpty()
DebugLog.i("TraeLogin", "ExchangeToken: http=${resp.code} bodyLen=${body.length}")
val root = runCatching { Json.parseToJsonElement(body).jsonObject }.getOrNull() ?: return@use null
val data = root["Result"]?.jsonObject ?: root["result"]?.jsonObject ?: root
val token = data["Token"]?.jsonPrimitive?.contentOrNull
?: data["token"]?.jsonPrimitive?.contentOrNull
?: data["AccessToken"]?.jsonPrimitive?.contentOrNull
?: data["accessToken"]?.jsonPrimitive?.contentOrNull
if (token.isNullOrBlank()) {
DebugLog.w("TraeLogin", "ExchangeToken: 返回无 Token(keys=${data.keys})")
return@use null
}
val newRefresh = data["RefreshToken"]?.jsonPrimitive?.contentOrNull
?: data["refreshToken"]?.jsonPrimitive?.contentOrNull
?: refreshToken
TraeExchangeResult(token, newRefresh)
}
} catch (e: Exception) {
DebugLog.w("TraeLogin", "ExchangeToken: 异常=" + (e.message ?: "unknown"))
null
}
}
// ─── WorkBuddy ─────────────────────────────────────────────────────────
private fun bindWorkBuddy() {

View File

@ -423,6 +423,7 @@
<string name="error_select_model_first">请先选择模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string>
<string name="error_login_url_missing">未配置登录 URL</string>
<string name="error_login_token_missing">登录成功但未获取到令牌,请复制回调地址里的 userJwt 后手动粘贴</string>
<string name="error_login_timeout">登录超时,请重试</string>
<string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string>
<string name="error_oauth_auth_failed">授权失败: %1$s</string>

View File

@ -423,6 +423,7 @@
<string name="error_select_model_first">請先選擇模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string>
<string name="error_login_url_missing">未配置登入 URL</string>
<string name="error_login_token_missing">登入成功但未取得令牌,請複製回呼位址中的 userJwt 後手動貼上</string>
<string name="error_login_timeout">登入逾時,請重試</string>
<string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string>
<string name="error_oauth_auth_failed">授權失敗: %1$s</string>

View File

@ -426,6 +426,7 @@
<string name="error_select_model_first">Please select a model first</string>
<string name="error_cookie_not_found">No cookie captured, please make sure you\'re logged in</string>
<string name="error_login_url_missing">No login URL configured</string>
<string name="error_login_token_missing">Login succeeded but no token was received. Copy userJwt from the callback URL and paste it manually.</string>
<string name="error_login_timeout">Login timed out, please retry</string>
<string name="error_oauth_url_prefix">URL should start with %1$s</string>
<string name="error_oauth_auth_failed">Authorization failed: %1$s</string>