fix(trae): 支持回调仅带 refreshToken 时自动兑换 JWT
- 登录回调不再只认 userJwt;老流程只回传 refreshToken 时调用 ExchangeToken 兑换 Cloud-IDE-JWT - 兑换失败给出明确提示,不再永远停在 Authenticating
This commit is contained in:
parent
57bd49f470
commit
9db3e01d49
@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.rainy.token"
|
applicationId = "com.rainy.token"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 33
|
versionCode = 34
|
||||||
versionName = "1.7.13"
|
versionName = "1.7.14"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@ -195,52 +195,76 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败")
|
DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志)
|
// 只记录参数名(不含 token 值,避免把 token 明文落调试日志)
|
||||||
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}")
|
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")
|
val userJwtRaw = parsed.queryParameter("userJwt")
|
||||||
?: parsed.queryParameter("user_jwt")
|
?: 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 {
|
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(
|
val credential = Credential.TraeCredential(
|
||||||
service = ServiceType.TRAE,
|
service = ServiceType.TRAE,
|
||||||
jwt = token,
|
jwt = token,
|
||||||
region = if (region == "INTL") "INTL" else "CN",
|
region = if (region == "INTL") "INTL" else "CN",
|
||||||
deviceId = deviceId,
|
deviceId = deviceId,
|
||||||
userId = userId?.takeIf { it.isNotBlank() },
|
userId = parsedUserId?.takeIf { it.isNotBlank() },
|
||||||
lastVerifiedAt = System.currentTimeMillis()
|
lastVerifiedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
val effectiveId = credentialRepository.save(credential, accountId)
|
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 ─────────────────────────────────────────────────────────
|
// ─── WorkBuddy ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun bindWorkBuddy() {
|
private fun bindWorkBuddy() {
|
||||||
|
|||||||
@ -423,6 +423,7 @@
|
|||||||
<string name="error_select_model_first">请先选择模型</string>
|
<string name="error_select_model_first">请先选择模型</string>
|
||||||
<string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string>
|
<string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string>
|
||||||
<string name="error_login_url_missing">未配置登录 URL</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_login_timeout">登录超时,请重试</string>
|
||||||
<string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string>
|
<string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string>
|
||||||
<string name="error_oauth_auth_failed">授权失败: %1$s</string>
|
<string name="error_oauth_auth_failed">授权失败: %1$s</string>
|
||||||
|
|||||||
@ -423,6 +423,7 @@
|
|||||||
<string name="error_select_model_first">請先選擇模型</string>
|
<string name="error_select_model_first">請先選擇模型</string>
|
||||||
<string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string>
|
<string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string>
|
||||||
<string name="error_login_url_missing">未配置登入 URL</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_login_timeout">登入逾時,請重試</string>
|
||||||
<string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string>
|
<string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string>
|
||||||
<string name="error_oauth_auth_failed">授權失敗: %1$s</string>
|
<string name="error_oauth_auth_failed">授權失敗: %1$s</string>
|
||||||
|
|||||||
@ -426,6 +426,7 @@
|
|||||||
<string name="error_select_model_first">Please select a model first</string>
|
<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_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_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_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_url_prefix">URL should start with %1$s</string>
|
||||||
<string name="error_oauth_auth_failed">Authorization failed: %1$s</string>
|
<string name="error_oauth_auth_failed">Authorization failed: %1$s</string>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user